feat: wire mysql delivery to awx callbacks

This commit is contained in:
mac
2026-07-28 14:45:17 +08:00
parent 5a260c443f
commit 8e09c30d21
13 changed files with 676 additions and 218 deletions
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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']
+11
View File
@@ -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',
+172 -20
View File
@@ -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
View File
@@ -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=
+17 -1
View File
@@ -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 == "" {
+14
View File
@@ -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"`
+1
View File
@@ -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)
+46 -1
View File
@@ -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 {
+142 -17
View File
@@ -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,
+27 -28
View File
@@ -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)