feat: wire mysql delivery to awx callbacks
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
- name: Gather host facts for delivery directory discovery
|
||||
hosts: "{{ target_hosts | default('all') }}"
|
||||
become: true
|
||||
gather_facts: true
|
||||
tasks:
|
||||
- name: Show discovered mounts
|
||||
ansible.builtin.debug:
|
||||
var: ansible_mounts
|
||||
+210
-132
@@ -1,56 +1,78 @@
|
||||
---
|
||||
- name: Preflight native MySQL delivery
|
||||
hosts: all
|
||||
- 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] }}"
|
||||
"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 }}"
|
||||
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 }}"
|
||||
# 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_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') }}"
|
||||
# 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') }}"
|
||||
|
||||
# --- 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('') }}"
|
||||
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:
|
||||
@@ -70,73 +92,85 @@
|
||||
failed_when: false
|
||||
no_log: true
|
||||
|
||||
- name: Validate prototype parameters
|
||||
- name: Validate delivery parameters
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- mysql_topology_value == '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_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_cpu_cores_value | int) in [1, 2, 4, 8, 16]
|
||||
- (mysql_memory_mb_value | int) >= 1024
|
||||
- (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_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_lower_case_table_names | int) in [0, 1]
|
||||
- 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
|
||||
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 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
|
||||
- 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: 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)
|
||||
- (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
|
||||
- (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:
|
||||
@@ -175,13 +209,46 @@
|
||||
failed_when: false
|
||||
no_log: true
|
||||
|
||||
- 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
|
||||
@@ -193,14 +260,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
|
||||
@@ -210,6 +277,8 @@
|
||||
# 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
|
||||
|
||||
@@ -218,7 +287,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
|
||||
@@ -228,6 +297,7 @@
|
||||
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' }
|
||||
@@ -235,7 +305,6 @@
|
||||
- { 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:
|
||||
@@ -287,9 +356,36 @@
|
||||
ansible.builtin.set_fact:
|
||||
mysql_node_index: "{{ ansible_play_hosts_all.index(inventory_hostname) }}"
|
||||
mysql_node_role: >-
|
||||
{{ 'standalone' if mysql_topology_value == 'standalone' else
|
||||
{{ 'standalone' if topology == 'standalone' else
|
||||
('primary' if ansible_play_hosts_all.index(inventory_hostname) == 0 else
|
||||
('replica' if mysql_topology_value == 'primary_replica' else 'mgr')) }}
|
||||
('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:
|
||||
@@ -300,13 +396,6 @@
|
||||
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:
|
||||
@@ -316,7 +405,6 @@
|
||||
- --user=mysql
|
||||
args:
|
||||
creates: "{{ mysql_data_dir }}/auto.cnf"
|
||||
no_log: true
|
||||
|
||||
- name: Install the delivery systemd template
|
||||
ansible.builtin.copy:
|
||||
@@ -327,30 +415,10 @@
|
||||
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
|
||||
when: mysql_systemd_unit.changed
|
||||
|
||||
- name: Start the MySQL delivery instance
|
||||
ansible.builtin.systemd_service:
|
||||
@@ -364,32 +432,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_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:
|
||||
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
|
||||
@@ -454,6 +523,15 @@
|
||||
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 }}"
|
||||
|
||||
+23
-18
@@ -102,15 +102,15 @@
|
||||
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
|
||||
{% 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
|
||||
done
|
||||
fi
|
||||
{% endfor %}
|
||||
executable: /bin/bash
|
||||
vars:
|
||||
mysql_probe_ports: "{{ [mysql_port_value, mysql_gr_port_value] if topology == 'mgr_3' else [mysql_port_value] }}"
|
||||
@@ -123,19 +123,24 @@
|
||||
register: mysql_available_memory
|
||||
changed_when: false
|
||||
|
||||
- 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: 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_mount_avail | int) >= (mysql_storage_gb_value | int) * 1073741824
|
||||
- (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.
|
||||
@@ -234,6 +239,7 @@
|
||||
- { 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
|
||||
@@ -275,7 +281,7 @@
|
||||
else 8000 if (mysql_memory_gb | int) <= 64
|
||||
else 16000) }}
|
||||
mysql_redo_capacity: >-
|
||||
{{ innodb_redo_log_capacity if (innodb_redo_log_capacity is defined)
|
||||
{{ 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
|
||||
@@ -299,7 +305,6 @@
|
||||
- --user=mysql
|
||||
args:
|
||||
creates: "{{ mysql_data_dir }}/auto.cnf"
|
||||
no_log: true
|
||||
|
||||
- name: Install the delivery systemd template
|
||||
ansible.builtin.copy:
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ declare module 'vue' {
|
||||
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
|
||||
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
|
||||
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
|
||||
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface DeliveryTarget {
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
export interface DeliveryMountPath {
|
||||
path: string
|
||||
available_gi: number
|
||||
fstype?: string
|
||||
}
|
||||
|
||||
export interface CreateMySQLDeliveryPayload {
|
||||
business_line_id: number
|
||||
target_id: number
|
||||
@@ -83,6 +89,11 @@ export const deliveryApi = {
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async listTargetMountPaths(targetId: number, host: string): Promise<DeliveryMountPath[]> {
|
||||
const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async createMySQL(payload: CreateMySQLDeliveryPayload): Promise<DeliveryTask> {
|
||||
const data = await authRequest('/auth/api/v1/delivery/mysql', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -102,6 +102,12 @@
|
||||
<el-option v-for="target in deliveryTargets" :key="target.id" :label="target.name" :value="target.id" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
部署主机
|
||||
<el-select v-model="selectedTargetHost" placeholder="选择 AWX 主机" :disabled="!selectedTargetId || !targetHosts.length">
|
||||
<el-option v-for="host in targetHosts" :key="host.name" :label="targetHostLabel(host)" :value="host.name" />
|
||||
</el-select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-list">
|
||||
@@ -124,12 +130,21 @@
|
||||
<div class="config-label">数据盘</div>
|
||||
<div class="subform-grid">
|
||||
<label class="form-field">
|
||||
挂载点
|
||||
<el-select v-model="deliveryForm.dataDisk">
|
||||
<el-option label="/data" value="/data" />
|
||||
<el-option label="/disk1" value="/disk1" />
|
||||
<el-option label="/mnt/vol-1" value="/mnt/vol-1" />
|
||||
</el-select>
|
||||
数据目录
|
||||
<el-autocomplete
|
||||
v-model="deliveryForm.dataDisk"
|
||||
:fetch-suggestions="queryMountPathSuggestions"
|
||||
placeholder="选择或输入数据目录"
|
||||
:loading="mountPathsLoading"
|
||||
clearable
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="mount-path-option">
|
||||
<span>{{ item.path }}</span>
|
||||
<small>{{ mountPathMeta(item) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
参数模板
|
||||
@@ -245,7 +260,7 @@
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button :disabled="!deploymentId" :loading="taskRefreshing" :icon="Refresh" @click="refreshTaskSnapshot">刷新状态</el-button>
|
||||
<el-button :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
|
||||
<el-button v-if="canShowCancelDeployment" :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -336,7 +351,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { deliveryApi, type DeliveryTarget, type DeliveryTask, type TaskEvent } from '@/api/delivery'
|
||||
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeliveryTask, type TaskEvent } from '@/api/delivery'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
@@ -404,7 +419,7 @@ const basicServices = ref<Service[]>([
|
||||
status: '可交付',
|
||||
template: 'mysql-delivery@v1.8.3',
|
||||
runner: 'runner-02',
|
||||
versions: ['MySQL 8.0'],
|
||||
versions: ['MySQL 8.0', 'MySQL 8.4'],
|
||||
modes: [
|
||||
{ value: 'single', label: '单实例' },
|
||||
],
|
||||
@@ -543,11 +558,15 @@ const deliveryDone = ref(false)
|
||||
const deliveryFailed = ref(false)
|
||||
const canceling = ref(false)
|
||||
const deploymentId = ref('')
|
||||
const currentTaskStatus = ref('')
|
||||
const deliveryError = ref('')
|
||||
const deliveredHost = ref('')
|
||||
const deliveredPort = ref<number>()
|
||||
const deliveryTargets = ref<DeliveryTarget[]>([])
|
||||
const selectedTargetId = ref<number>()
|
||||
const selectedTargetHost = ref('')
|
||||
const mountPathOptions = ref<DeliveryMountPath[]>([])
|
||||
const mountPathsLoading = ref(false)
|
||||
const targetsLoading = ref(false)
|
||||
const taskRefreshing = ref(false)
|
||||
const taskRestoring = ref(false)
|
||||
@@ -582,6 +601,9 @@ const deliveryForm = reactive({
|
||||
const steps = ref<DeliveryStep[]>([])
|
||||
|
||||
const activeService = computed(() => basicServices.value.find((service) => service.key === activeServiceKey.value && !service.disabled))
|
||||
const selectedDeliveryTarget = computed(() => deliveryTargets.value.find((target) => target.id === selectedTargetId.value))
|
||||
const selectedTargetMetadata = computed(() => parseTargetMetadata(selectedDeliveryTarget.value?.metadata))
|
||||
const targetHosts = computed(() => selectedTargetMetadata.value.hosts)
|
||||
const taskNo = computed(() => `CMP-20260721-${activeServiceKey.value === 'mysql' ? '0024' : '0023'}`)
|
||||
const currentModeLabel = computed(() => activeService.value?.modes.find((mode) => mode.value === deliveryForm.mode)?.label || '-')
|
||||
const topologySummary = computed(() => `${currentModeLabel.value} · ${deliveryForm.spec} · ${deliveryForm.disk}`)
|
||||
@@ -623,21 +645,28 @@ const runnerPreview = computed(() => {
|
||||
})
|
||||
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port || 'auto'}`)
|
||||
const resultTitle = computed(() => {
|
||||
if (deliveryFailed.value) return '交付失败,已自动回退'
|
||||
if (currentTaskStatus.value === 'rollback_failed') return '交付失败,回滚失败'
|
||||
if (currentTaskStatus.value === 'rolled_back') return '交付失败,已回滚'
|
||||
if (deliveryFailed.value) return '交付失败'
|
||||
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
|
||||
})
|
||||
const resultSubtitle = computed(() => {
|
||||
if (currentTaskStatus.value === 'rollback_failed') return deliveryError.value || '自动回滚失败 · 需要人工清理'
|
||||
if (currentTaskStatus.value === 'rolled_back') return deliveryError.value || '资源已释放 · 变更未交付'
|
||||
if (deliveryFailed.value) return deliveryError.value || '交付失败 · 资源已释放 · 变更未交付'
|
||||
return activeServiceKey.value === 'mysql' ? '全部步骤执行成功 · 用时 06:42' : '全部步骤执行成功 · 用时 02:18'
|
||||
})
|
||||
const deliveryStateText = computed(() => {
|
||||
if (deliveryFailed.value) return '已回退'
|
||||
if (currentTaskStatus.value === 'rollback_failed') return '回滚失败'
|
||||
if (isRollbackRunningStatus(currentTaskStatus.value)) return '回滚中'
|
||||
if (deliveryFailed.value) return isRolledBackStatus(currentTaskStatus.value) ? '已回滚' : '失败'
|
||||
if (deliveryDone.value) return '已交付'
|
||||
if (running.value) return '交付中'
|
||||
if (precheckPassed.value) return '待执行'
|
||||
return '配置中'
|
||||
})
|
||||
const canCancelDeployment = computed(() => Boolean(deploymentId.value) && running.value && !deliveryDone.value && !deliveryFailed.value)
|
||||
const canShowCancelDeployment = computed(() => Boolean(deploymentId.value) && isCancelableDeliveryStatus(currentTaskStatus.value))
|
||||
const canCancelDeployment = computed(() => Boolean(deploymentId.value) && isCancelableDeliveryStatus(currentTaskStatus.value))
|
||||
const deliveryStateClass = computed(() => {
|
||||
if (deliveryFailed.value) return 'tag-red'
|
||||
if (deliveryDone.value) return 'tag-green'
|
||||
@@ -669,6 +698,16 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedTargetId, () => {
|
||||
if (selectedTargetHost.value && targetHosts.value.some((host) => host.name === selectedTargetHost.value)) return
|
||||
selectedTargetHost.value = targetHosts.value[0]?.name || ''
|
||||
void loadMountPaths()
|
||||
})
|
||||
|
||||
watch(selectedTargetHost, () => {
|
||||
void loadMountPaths()
|
||||
})
|
||||
|
||||
hydrateServiceDefaults()
|
||||
onMounted(async () => {
|
||||
await loadDeliveryTargets()
|
||||
@@ -743,6 +782,7 @@ function resetExecutionState() {
|
||||
deliveryError.value = ''
|
||||
canceling.value = false
|
||||
deploymentId.value = ''
|
||||
currentTaskStatus.value = ''
|
||||
deliveredHost.value = ''
|
||||
deliveredPort.value = undefined
|
||||
seenEventIds.value = new Set()
|
||||
@@ -806,6 +846,14 @@ async function createTask() {
|
||||
ElMessage.warning('请先选择部署目标')
|
||||
return
|
||||
}
|
||||
if (targetHosts.value.length && !selectedTargetHost.value) {
|
||||
ElMessage.warning('请先选择部署主机')
|
||||
return
|
||||
}
|
||||
if (!targetHosts.value.length) {
|
||||
ElMessage.warning('当前部署目标没有可用主机')
|
||||
return
|
||||
}
|
||||
const validationError = validateDeliveryForm()
|
||||
if (validationError) {
|
||||
ElMessage.warning(validationError)
|
||||
@@ -814,6 +862,7 @@ async function createTask() {
|
||||
running.value = true
|
||||
deliveryDone.value = false
|
||||
deliveryFailed.value = false
|
||||
currentTaskStatus.value = 'pending'
|
||||
activeView.value = 'execution'
|
||||
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
|
||||
try {
|
||||
@@ -854,9 +903,6 @@ function validateDeliveryForm() {
|
||||
if (storageGb < 20 || storageGb > 2000) {
|
||||
return '数据盘容量必须在 20GB 到 2000GB 之间'
|
||||
}
|
||||
if (!['/data', '/disk1', '/mnt/vol-1'].includes(deliveryForm.dataDisk)) {
|
||||
return '数据盘挂载点不在支持范围内'
|
||||
}
|
||||
if (!['default', 'high_performance', 'high_safety'].includes(deliveryForm.paramTemplate)) {
|
||||
return '参数模板不在支持范围内'
|
||||
}
|
||||
@@ -928,14 +974,16 @@ async function restoreActiveTask() {
|
||||
|
||||
function hydrateTaskSnapshot(task: DeliveryTask) {
|
||||
deploymentId.value = task.id
|
||||
currentTaskStatus.value = task.status || currentTaskStatus.value
|
||||
deliveredHost.value = task.target_host_ip || deliveredHost.value
|
||||
deliveredPort.value = task.mysql_port || deliveredPort.value
|
||||
selectedTargetId.value = task.target_id || selectedTargetId.value
|
||||
selectedTargetHost.value = task.target_host || selectedTargetHost.value
|
||||
deliveryForm.instanceName = task.instance_name || deliveryForm.instanceName
|
||||
if (task.status) {
|
||||
running.value = !isTerminalDeliveryStatus(task.status)
|
||||
deliveryDone.value = task.status === 'finished'
|
||||
deliveryFailed.value = ['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(task.status)
|
||||
deliveryFailed.value = isFailedDeliveryStatus(task.status)
|
||||
}
|
||||
if (!deliveryLog.value || deliveryLog.value === '[ready] 等待创建交付任务...') {
|
||||
deliveryLog.value = `[task] ${task.id} restored from ${task.status}`
|
||||
@@ -1000,6 +1048,7 @@ function mysqlDeliveryPayload(businessLineId: number) {
|
||||
cpu_milli: resources.cpuCores * 1000,
|
||||
memory_mi: resources.memoryGb * 1024,
|
||||
storage_gi: parseStorageGb(deliveryForm.disk),
|
||||
target_host: selectedTargetHost.value,
|
||||
param_template: deliveryForm.paramTemplate,
|
||||
timezone: deliveryForm.timezone,
|
||||
lower_case_table_names: deliveryForm.lowerCaseTableNames,
|
||||
@@ -1090,8 +1139,9 @@ function appendLog(message: unknown) {
|
||||
}
|
||||
|
||||
function applyDeliveryStatus(status: string, message: string) {
|
||||
currentTaskStatus.value = status || currentTaskStatus.value
|
||||
if (message) appendLog(message)
|
||||
if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling'].includes(status)) {
|
||||
if (isActiveDeliveryStatus(status)) {
|
||||
running.value = true
|
||||
markStepRunning()
|
||||
return
|
||||
@@ -1103,12 +1153,16 @@ function applyDeliveryStatus(status: string, message: string) {
|
||||
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
|
||||
return
|
||||
}
|
||||
if (['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)) {
|
||||
if (isFailedDeliveryStatus(status)) {
|
||||
running.value = false
|
||||
deliveryDone.value = false
|
||||
deliveryFailed.value = true
|
||||
deliveryError.value = message || deliveryError.value
|
||||
markCurrentStepFailed()
|
||||
if (status === 'rolled_back') {
|
||||
steps.value = steps.value.map((step) => step.state === 'failed' ? step : { ...step, state: step.state === 'pending' ? 'done' : step.state })
|
||||
} else {
|
||||
markCurrentStepFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1117,6 +1171,8 @@ async function loadDeliveryTargets() {
|
||||
try {
|
||||
deliveryTargets.value = await deliveryApi.listTargets()
|
||||
selectedTargetId.value = deliveryTargets.value[0]?.id
|
||||
selectedTargetHost.value = targetHosts.value[0]?.name || ''
|
||||
await loadMountPaths()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败')
|
||||
} finally {
|
||||
@@ -1124,6 +1180,22 @@ async function loadDeliveryTargets() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMountPaths() {
|
||||
if (!selectedTargetId.value || !selectedTargetHost.value) {
|
||||
mountPathOptions.value = defaultMountPathOptions()
|
||||
return
|
||||
}
|
||||
mountPathsLoading.value = true
|
||||
try {
|
||||
const items = await deliveryApi.listTargetMountPaths(selectedTargetId.value, selectedTargetHost.value)
|
||||
mountPathOptions.value = items.length ? items : defaultMountPathOptions()
|
||||
} catch {
|
||||
mountPathOptions.value = defaultMountPathOptions()
|
||||
} finally {
|
||||
mountPathsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseSpec(spec: string) {
|
||||
const cpu = Number(spec.match(/(\d+)\s*C/i)?.[1] || 1)
|
||||
const memory = Number(spec.match(/\/\s*(\d+)\s*G/i)?.[1] || 1)
|
||||
@@ -1144,6 +1216,50 @@ function mysqlVersionValue(version: string) {
|
||||
return matched.split('.').slice(0, 2).join('.')
|
||||
}
|
||||
|
||||
interface TargetMetadata {
|
||||
hosts?: TargetHost[]
|
||||
}
|
||||
|
||||
interface TargetHost {
|
||||
name: string
|
||||
ip?: string
|
||||
}
|
||||
|
||||
function parseTargetMetadata(raw?: string): { hosts: TargetHost[] } {
|
||||
if (!raw) return { hosts: [] }
|
||||
try {
|
||||
const metadata = JSON.parse(raw) as TargetMetadata
|
||||
return {
|
||||
hosts: Array.isArray(metadata.hosts) ? metadata.hosts.filter((host) => Boolean(host.name)) : [],
|
||||
}
|
||||
} catch {
|
||||
return { hosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function targetHostLabel(host: TargetHost) {
|
||||
return host.ip ? `${host.name} · ${host.ip}` : host.name
|
||||
}
|
||||
|
||||
function mountPathMeta(item: DeliveryMountPath) {
|
||||
const parts = []
|
||||
if (item.available_gi > 0) parts.push(`可用 ${item.available_gi}GiB`)
|
||||
if (item.fstype) parts.push(item.fstype)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function queryMountPathSuggestions(query: string, callback: (items: Array<DeliveryMountPath & { value: string }>) => void) {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const items = mountPathOptions.value
|
||||
.filter((item) => !keyword || item.path.toLowerCase().includes(keyword))
|
||||
.map((item) => ({ ...item, value: item.path }))
|
||||
callback(items)
|
||||
}
|
||||
|
||||
function defaultMountPathOptions(): DeliveryMountPath[] {
|
||||
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
|
||||
}
|
||||
|
||||
function mysqlTopologyValue(mode: string) {
|
||||
if (mode === 'single') return 'standalone'
|
||||
if (mode === 'replica') return 'primary_replica'
|
||||
@@ -1187,7 +1303,31 @@ function normalizeDNSLabel(value: string) {
|
||||
}
|
||||
|
||||
function isTerminalDeliveryStatus(status: string) {
|
||||
return ['finished', 'execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)
|
||||
return ['finished', ...failedDeliveryStatuses].includes(status)
|
||||
}
|
||||
|
||||
const activeDeliveryStatuses = ['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling', 'rollback_pending', 'rolling_back']
|
||||
const failedDeliveryStatuses = ['execution_failed', 'validation_failed', 'register_failed', 'canceled', 'rollback_failed', 'rolled_back']
|
||||
const cancelableDeliveryStatuses = ['pending', 'dispatching', 'running']
|
||||
|
||||
function isActiveDeliveryStatus(status: string) {
|
||||
return activeDeliveryStatuses.includes(status)
|
||||
}
|
||||
|
||||
function isFailedDeliveryStatus(status: string) {
|
||||
return failedDeliveryStatuses.includes(status)
|
||||
}
|
||||
|
||||
function isCancelableDeliveryStatus(status: string) {
|
||||
return cancelableDeliveryStatuses.includes(status)
|
||||
}
|
||||
|
||||
function isRollbackRunningStatus(status: string) {
|
||||
return ['rollback_pending', 'rolling_back'].includes(status)
|
||||
}
|
||||
|
||||
function isRolledBackStatus(status: string) {
|
||||
return status === 'rolled_back'
|
||||
}
|
||||
|
||||
function markStepRunning() {
|
||||
@@ -1450,6 +1590,18 @@ h4 {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.mount-path-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mount-path-option small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
+3
-1
@@ -25,12 +25,14 @@ DELIVERY_TARGET_LIMIT=2
|
||||
# 单机 MySQL 实例数上限(同机多实例,容量由配额 + playbook 实机守卫兜底)
|
||||
DELIVERY_HOST_INSTANCE_LIMIT=4
|
||||
# 数据盘挂载点白名单(逗号分隔,第一项为默认值)
|
||||
DELIVERY_DATA_DISKS=/data
|
||||
DELIVERY_DATA_DISKS=/data,/disk1,/mnt,/opt/mysql-delivery
|
||||
AWX_BASE_URL=
|
||||
AWX_TOKEN=
|
||||
AWX_USERNAME=
|
||||
AWX_PASSWORD=
|
||||
AWX_WEBHOOK_TOKEN=
|
||||
AWX_FACTS_TEMPLATE_ID=
|
||||
AWX_FACTS_TIMEOUT_SECONDS=45
|
||||
CLOUDDM_REGISTER_URL=
|
||||
CLOUDDM_API_TOKEN=
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ type Config struct {
|
||||
AWXUsername string
|
||||
AWXPassword string
|
||||
AWXWebhookToken string
|
||||
AWXFactsTemplateID uint64
|
||||
AWXFactsTimeoutSeconds int
|
||||
DeliverySchedulerEnabled bool
|
||||
DeliveryDispatchSeconds int
|
||||
DeliveryCallbackBaseURL string
|
||||
@@ -134,6 +136,8 @@ func Load() Config {
|
||||
AWXUsername: env("AWX_USERNAME", ""),
|
||||
AWXPassword: env("AWX_PASSWORD", ""),
|
||||
AWXWebhookToken: env("AWX_WEBHOOK_TOKEN", ""),
|
||||
AWXFactsTemplateID: envUint64("AWX_FACTS_TEMPLATE_ID", 0),
|
||||
AWXFactsTimeoutSeconds: envInt("AWX_FACTS_TIMEOUT_SECONDS", 45),
|
||||
DeliverySchedulerEnabled: envBool("DELIVERY_SCHEDULER_ENABLED", false),
|
||||
DeliveryDispatchSeconds: envInt("DELIVERY_DISPATCH_SECONDS", 5),
|
||||
DeliveryCallbackBaseURL: trimURL(env("DELIVERY_CALLBACK_BASE_URL", publicBaseURL)),
|
||||
@@ -141,7 +145,7 @@ func Load() Config {
|
||||
DeliveryGlobalLimit: envInt("DELIVERY_GLOBAL_LIMIT", 2),
|
||||
DeliveryTargetLimit: envInt("DELIVERY_TARGET_LIMIT", 2),
|
||||
DeliveryHostInstanceLimit: envInt("DELIVERY_HOST_INSTANCE_LIMIT", 4),
|
||||
DeliveryDataDisks: splitCSV(env("DELIVERY_DATA_DISKS", "/data")),
|
||||
DeliveryDataDisks: splitCSV(env("DELIVERY_DATA_DISKS", "/data,/disk1,/mnt,/opt/mysql-delivery")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +249,18 @@ func envInt(key string, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envUint64(key string, fallback uint64) uint64 {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func defaultPublicBaseURL(httpAddr string) string {
|
||||
addr := strings.TrimSpace(httpAddr)
|
||||
if addr == "" {
|
||||
|
||||
@@ -261,6 +261,20 @@ func (h *DeliveryHandler) Targets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *DeliveryHandler) TargetHostMountPaths(c *gin.Context) {
|
||||
targetID, err := strconv.ParseUint(c.Param("target_id"), 10, 64)
|
||||
if err != nil || targetID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_id"})
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
type quotaPayload struct {
|
||||
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
|
||||
TargetID uint64 `json:"target_id" binding:"required"`
|
||||
|
||||
@@ -145,6 +145,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
||||
protected.GET("/container-services/business-lines/:id/workloads", containerServiceHandler.Workloads)
|
||||
protected.GET("/clouddm/login", clouddmHandler.Login)
|
||||
protected.GET("/delivery/targets", deliveryHandler.Targets)
|
||||
protected.GET("/delivery/targets/:target_id/hosts/:host/mount-paths", deliveryHandler.TargetHostMountPaths)
|
||||
protected.PUT("/delivery/quotas", deliveryHandler.UpsertQuota)
|
||||
protected.POST("/delivery/mysql", deliveryHandler.CreateMySQL)
|
||||
protected.GET("/delivery/tasks", deliveryHandler.List)
|
||||
|
||||
@@ -87,7 +87,13 @@ func (c *AWXClient) Configured() bool {
|
||||
}
|
||||
|
||||
func (c *AWXClient) Launch(ctx context.Context, templateID uint64, input AWXLaunchRequest) (*AWXJob, error) {
|
||||
body := map[string]any{"inventory": input.InventoryID, "extra_vars": input.ExtraVars}
|
||||
body := map[string]any{}
|
||||
if input.InventoryID != 0 {
|
||||
body["inventory"] = input.InventoryID
|
||||
}
|
||||
if input.ExtraVars != nil {
|
||||
body["extra_vars"] = input.ExtraVars
|
||||
}
|
||||
if input.Limit != "" {
|
||||
body["limit"] = input.Limit
|
||||
}
|
||||
@@ -141,6 +147,31 @@ func (c *AWXClient) Cancel(ctx context.Context, jobID string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v2/jobs/"+jobID+"/cancel/", map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (c *AWXClient) WaitJob(ctx context.Context, jobID string, timeout time.Duration) (*AWXJob, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
deadlineCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
job, err := c.GetJob(deadlineCtx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch job.Status {
|
||||
case "successful", "failed", "error", "canceled":
|
||||
return job, nil
|
||||
}
|
||||
select {
|
||||
case <-deadlineCtx.Done():
|
||||
return nil, fmt.Errorf("AWX job %s did not finish within %s", jobID, timeout)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AWXClient) ListJobTemplates(ctx context.Context) ([]AWXJobTemplate, error) {
|
||||
var out []AWXJobTemplate
|
||||
path := "/api/v2/job_templates/?page_size=200"
|
||||
@@ -186,6 +217,20 @@ func (c *AWXClient) ListInventoryHosts(ctx context.Context, inventoryID uint64)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AWXClient) GetHostFacts(ctx context.Context, hostID uint64) (map[string]any, error) {
|
||||
if hostID == 0 {
|
||||
return nil, fmt.Errorf("invalid AWX host id")
|
||||
}
|
||||
var facts map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/hosts/%d/ansible_facts/", hostID), nil, &facts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nested, ok := facts["ansible_facts"].(map[string]any); ok {
|
||||
return nested, nil
|
||||
}
|
||||
return facts, nil
|
||||
}
|
||||
|
||||
func (c *AWXClient) request(ctx context.Context, method, path string, payload any, output any) error {
|
||||
raw, err := c.requestRaw(ctx, method, path, payload, "application/json")
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -72,6 +74,12 @@ type DeliveryTarget struct {
|
||||
Metadata string `json:"metadata"`
|
||||
}
|
||||
|
||||
type DeliveryMountPath struct {
|
||||
Path string `json:"path"`
|
||||
AvailableGi int64 `json:"available_gi"`
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
}
|
||||
|
||||
type DeliveryStageEventInput struct {
|
||||
Stage string `json:"stage" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
@@ -125,6 +133,81 @@ func parseTargetMetadata(raw string) targetMetadata {
|
||||
return meta
|
||||
}
|
||||
|
||||
func mountPathsFromFacts(facts map[string]any) []DeliveryMountPath {
|
||||
rawMounts, ok := facts["ansible_mounts"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := make([]DeliveryMountPath, 0, len(rawMounts))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range rawMounts {
|
||||
mount, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(stringValue(mount["mount"]))
|
||||
if path == "" || !strings.HasPrefix(path, "/") {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[path]; exists {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
items = append(items, DeliveryMountPath{
|
||||
Path: path,
|
||||
AvailableGi: bytesToGi(int64Value(mount["size_available"])),
|
||||
FSType: strings.TrimSpace(stringValue(mount["fstype"])),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Path == "/" {
|
||||
return false
|
||||
}
|
||||
if items[j].Path == "/" {
|
||||
return true
|
||||
}
|
||||
return items[i].Path < items[j].Path
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func int64Value(value any) int64 {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func bytesToGi(bytes int64) int64 {
|
||||
if bytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return bytes / 1073741824
|
||||
}
|
||||
|
||||
// firstFreeHost 返回候选池中非失败任务数未达单机实例上限的第一个节点。
|
||||
func firstFreeHost(hosts []targetHost, occupied []string, limit int) *targetHost {
|
||||
if limit < 1 {
|
||||
@@ -222,6 +305,64 @@ func (s *DeliveryService) getTarget(ctx context.Context, templateID uint64) (Del
|
||||
return s.awxDeliveryTarget(ctx, *template)
|
||||
}
|
||||
|
||||
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string) ([]DeliveryMountPath, error) {
|
||||
if targetID == 0 {
|
||||
return nil, fmt.Errorf("target_id is required")
|
||||
}
|
||||
if hostName == "" || len(hostName) > 253 || !hostNamePattern.MatchString(hostName) {
|
||||
return nil, fmt.Errorf("host must be a valid inventory host name")
|
||||
}
|
||||
template, err := s.awx.GetJobTemplate(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deployment target is unavailable: %w", err)
|
||||
}
|
||||
hosts, err := s.awx.ListInventoryHosts(ctx, template.Inventory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var matched *AWXInventoryHost
|
||||
for i := range hosts {
|
||||
if hosts[i].Enabled && hosts[i].Name == hostName {
|
||||
matched = &hosts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, fmt.Errorf("host %q is not in the deployment target inventory", hostName)
|
||||
}
|
||||
if s.cfg.AWXFactsTemplateID != 0 {
|
||||
if err := s.refreshHostFacts(ctx, hostName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
facts, err := s.awx.GetHostFacts(ctx, matched.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := mountPathsFromFacts(facts)
|
||||
if items == nil {
|
||||
items = []DeliveryMountPath{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string) error {
|
||||
job, err := s.awx.Launch(ctx, s.cfg.AWXFactsTemplateID, AWXLaunchRequest{
|
||||
Limit: hostName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch AWX facts job: %w", err)
|
||||
}
|
||||
done, err := s.awx.WaitJob(ctx, strconv.FormatUint(job.ID, 10), time.Duration(s.cfg.AWXFactsTimeoutSeconds)*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done.Status != "successful" || done.Failed {
|
||||
return fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) awxDeliveryTarget(ctx context.Context, template AWXJobTemplate) (DeliveryTarget, error) {
|
||||
if !template.AskVariablesOnLaunch || !template.AskLimitOnLaunch {
|
||||
return DeliveryTarget{}, fmt.Errorf("AWX job template %d must enable Prompt on launch for Variables and Limit", template.ID)
|
||||
@@ -424,22 +565,6 @@ func validateDeliveryInput(input MySQLDeliveryInput, dataDisks []string) error {
|
||||
if input.MySQLPort != 0 && (input.MySQLPort < mysqlPortPoolStart || input.MySQLPort > mysqlPortPoolEnd) {
|
||||
return fmt.Errorf("mysql_port must be empty for auto assignment or between %d and %d", mysqlPortPoolStart, mysqlPortPoolEnd)
|
||||
}
|
||||
if input.DataDisk != "" {
|
||||
allowed := dataDisks
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{"/data"}
|
||||
}
|
||||
found := false
|
||||
for _, disk := range allowed {
|
||||
if input.DataDisk == disk {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("data_disk %q is not in the allowed mount point list %v", input.DataDisk, allowed)
|
||||
}
|
||||
}
|
||||
if input.TargetHost != "" && (len(input.TargetHost) > 253 || !hostNamePattern.MatchString(input.TargetHost)) {
|
||||
return fmt.Errorf("target_host must be a valid inventory host name")
|
||||
}
|
||||
@@ -889,7 +1014,7 @@ func (s *DeliveryService) CreateExecution(ctx context.Context, taskID, payloadHa
|
||||
"target_hosts": task.TargetHost, "topology": topology,
|
||||
"instance_name": payload.InstanceName, "mysql_port": task.MySQLPort,
|
||||
"data_disk": payload.DataDisk, "cpu_cores": payload.CPUCores,
|
||||
"memory_gb": payload.MemoryGB, "storage_gb": payload.StorageGB,
|
||||
"memory_mb": payload.MemoryMi, "storage_gb": payload.StorageGi,
|
||||
"cpu_milli": payload.CPUMilli, "memory_mi": payload.MemoryMi, "storage_gi": payload.StorageGi,
|
||||
"mysql_version": payload.MySQLVersion, "param_template": payload.ParamTemplate,
|
||||
"target_host": payload.TargetHost,
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestValidateDeliveryInput(t *testing.T) {
|
||||
full.MySQLVersion = "8.0"
|
||||
full.Topology = "standalone"
|
||||
full.MySQLPort = 13306
|
||||
full.DataDisk = "/disk1"
|
||||
full.DataDisk = "/var/lib/mysql01"
|
||||
full.TargetHost = "k8s-server-03"
|
||||
full.CPUMilli = 2000
|
||||
full.MemoryMi = 8192
|
||||
@@ -44,33 +44,32 @@ func TestValidateDeliveryInput(t *testing.T) {
|
||||
t.Fatalf("8.4 LTS rejected: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*MySQLDeliveryInput){
|
||||
"uppercase namespace": func(in *MySQLDeliveryInput) { in.Namespace = "Team-A" },
|
||||
"bad instance": func(in *MySQLDeliveryInput) { in.InstanceName = "mysql_01" },
|
||||
"too little cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 50 },
|
||||
"too much cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 65000 },
|
||||
"too little memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 1024 },
|
||||
"too much memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 131072 },
|
||||
"too little storage": func(in *MySQLDeliveryInput) { in.StorageGi = 10 },
|
||||
"too much storage": func(in *MySQLDeliveryInput) { in.StorageGi = 4000 },
|
||||
"unsupported version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.7" },
|
||||
"eol version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.6" },
|
||||
"unsupported topology": func(in *MySQLDeliveryInput) { in.Topology = "mgr_3" },
|
||||
"port below pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 3307 },
|
||||
"port above pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 14000 },
|
||||
"data disk not in list": func(in *MySQLDeliveryInput) { in.DataDisk = "/mnt/other" },
|
||||
"bad target host": func(in *MySQLDeliveryInput) { in.TargetHost = "-bad-host" },
|
||||
"bad timezone": func(in *MySQLDeliveryInput) { in.TimeZone = "UTC+8" },
|
||||
"bad lower case": func(in *MySQLDeliveryInput) { in.LowerCaseTableNames = 2 },
|
||||
"bad charset": func(in *MySQLDeliveryInput) { in.CharacterSet = "big5" },
|
||||
"collation mismatch": func(in *MySQLDeliveryInput) { in.CharacterSet = "gbk"; in.Collation = "utf8mb4_general_ci" },
|
||||
"bad max connections": func(in *MySQLDeliveryInput) { in.MaxConnections = "300" },
|
||||
"bad redo capacity": func(in *MySQLDeliveryInput) { in.InnoDBRedoLogCapacity = "2G" },
|
||||
"bad flush log": func(in *MySQLDeliveryInput) { in.InnoDBFlushLogAtTrxCommit = 3 },
|
||||
"bad sync binlog": func(in *MySQLDeliveryInput) { in.SyncBinlog = 2 },
|
||||
"bad io capacity": func(in *MySQLDeliveryInput) { in.InnoDBIOCapacity = 500 },
|
||||
"bad long query time": func(in *MySQLDeliveryInput) { in.LongQueryTime = 3 },
|
||||
"bad binlog expire": func(in *MySQLDeliveryInput) { in.BinlogExpireLogsSeconds = 3600 },
|
||||
"bad max binlog size": func(in *MySQLDeliveryInput) { in.MaxBinlogSize = "64M" },
|
||||
"uppercase namespace": func(in *MySQLDeliveryInput) { in.Namespace = "Team-A" },
|
||||
"bad instance": func(in *MySQLDeliveryInput) { in.InstanceName = "mysql_01" },
|
||||
"too little cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 50 },
|
||||
"too much cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 65000 },
|
||||
"too little memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 1024 },
|
||||
"too much memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 131072 },
|
||||
"too little storage": func(in *MySQLDeliveryInput) { in.StorageGi = 10 },
|
||||
"too much storage": func(in *MySQLDeliveryInput) { in.StorageGi = 4000 },
|
||||
"unsupported version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.7" },
|
||||
"eol version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.6" },
|
||||
"unsupported topology": func(in *MySQLDeliveryInput) { in.Topology = "mgr_3" },
|
||||
"port below pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 3307 },
|
||||
"port above pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 14000 },
|
||||
"bad target host": func(in *MySQLDeliveryInput) { in.TargetHost = "-bad-host" },
|
||||
"bad timezone": func(in *MySQLDeliveryInput) { in.TimeZone = "UTC+8" },
|
||||
"bad lower case": func(in *MySQLDeliveryInput) { in.LowerCaseTableNames = 2 },
|
||||
"bad charset": func(in *MySQLDeliveryInput) { in.CharacterSet = "big5" },
|
||||
"collation mismatch": func(in *MySQLDeliveryInput) { in.CharacterSet = "gbk"; in.Collation = "utf8mb4_general_ci" },
|
||||
"bad max connections": func(in *MySQLDeliveryInput) { in.MaxConnections = "300" },
|
||||
"bad redo capacity": func(in *MySQLDeliveryInput) { in.InnoDBRedoLogCapacity = "2G" },
|
||||
"bad flush log": func(in *MySQLDeliveryInput) { in.InnoDBFlushLogAtTrxCommit = 3 },
|
||||
"bad sync binlog": func(in *MySQLDeliveryInput) { in.SyncBinlog = 2 },
|
||||
"bad io capacity": func(in *MySQLDeliveryInput) { in.InnoDBIOCapacity = 500 },
|
||||
"bad long query time": func(in *MySQLDeliveryInput) { in.LongQueryTime = 3 },
|
||||
"bad binlog expire": func(in *MySQLDeliveryInput) { in.BinlogExpireLogsSeconds = 3600 },
|
||||
"bad max binlog size": func(in *MySQLDeliveryInput) { in.MaxBinlogSize = "64M" },
|
||||
} {
|
||||
input := valid
|
||||
mutate(&input)
|
||||
|
||||
Reference in New Issue
Block a user