diff --git a/ansible/gather-host-facts.yml b/ansible/gather-host-facts.yml new file mode 100644 index 0000000..60efc17 --- /dev/null +++ b/ansible/gather-host-facts.yml @@ -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 diff --git a/ansible/mysql-deploy-callback.yml b/ansible/mysql-deploy-callback.yml index ca54337..434a113 100644 --- a/ansible/mysql-deploy-callback.yml +++ b/ansible/mysql-deploy-callback.yml @@ -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 }}" diff --git a/ansible/mysql-deploy.yml b/ansible/mysql-deploy.yml index 4bbfb2d..c66e2c5 100644 --- a/ansible/mysql-deploy.yml +++ b/ansible/mysql-deploy.yml @@ -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: diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 98a79b5..379b92d 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] diff --git a/frontend/src/api/delivery.ts b/frontend/src/api/delivery.ts index a774ef5..4593cb5 100644 --- a/frontend/src/api/delivery.ts +++ b/frontend/src/api/delivery.ts @@ -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 { + 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 { const data = await authRequest('/auth/api/v1/delivery/mysql', { method: 'POST', diff --git a/frontend/src/views/service/Catalog.vue b/frontend/src/views/service/Catalog.vue index 27dcdba..f9d5c2b 100644 --- a/frontend/src/views/service/Catalog.vue +++ b/frontend/src/views/service/Catalog.vue @@ -102,6 +102,12 @@ +
@@ -124,12 +130,21 @@
数据盘
刷新状态 - 取消任务 + 取消任务
@@ -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([ 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() const deliveryTargets = ref([]) const selectedTargetId = ref() +const selectedTargetHost = ref('') +const mountPathOptions = ref([]) +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([]) 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) => 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); } diff --git a/server/.env.example b/server/.env.example index 3d7e087..00aac8f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 1732145..67da00b 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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 == "" { diff --git a/server/internal/handler/delivery.go b/server/internal/handler/delivery.go index d9f0622..a948acc 100644 --- a/server/internal/handler/delivery.go +++ b/server/internal/handler/delivery.go @@ -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"` diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 797517d..632ed6d 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -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) diff --git a/server/internal/service/awx.go b/server/internal/service/awx.go index bae334d..8e7b41f 100644 --- a/server/internal/service/awx.go +++ b/server/internal/service/awx.go @@ -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 { diff --git a/server/internal/service/delivery.go b/server/internal/service/delivery.go index 57947b0..b0e8ed6 100644 --- a/server/internal/service/delivery.go +++ b/server/internal/service/delivery.go @@ -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, diff --git a/server/internal/service/delivery_test.go b/server/internal/service/delivery_test.go index 9165cc1..2bb9746 100644 --- a/server/internal/service/delivery_test.go +++ b/server/internal/service/delivery_test.go @@ -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)