-
+
+
+ {{ topologyNodes.length }} 节点{{ topologyNodes.length > 1 ? `(1 主 ${topologyNodes.length - 1} 从)` : '' }}
+ {{ topologyNodes.length > 1 ? 'GTID 异步复制 · 从节点默认只读' : '单实例部署' }}
+
+
主库复制链路
+
+
{{ activeService.icon }}
{{ node.name }}
@@ -346,6 +352,7 @@
{{ node.role }}
{{ storageLabel }} 配额
+
@@ -423,6 +430,13 @@
{{ connectionReady ? '可连接' : '等待地址' }}
{{ resultAddress }}
+
+
REPLICA ENDPOINTS
+
+ 从节点{{ endpoint }}
+
+
+
{{ isPostgreSQL ? '使用 AWX 托管的 postgres 管理员凭据连接。' : '使用交付时设置的 root 管理员账号连接。' }}
{{ databaseCommand }}
@@ -534,7 +548,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
-import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
+import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeliveryTask, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
import { useAuthStore } from '@/stores/auth'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -617,6 +631,7 @@ interface WorkbenchSnapshot {
deploymentId: string
deliveryError: string
deliveredHost: string
+ deliveredHosts?: string[]
deliveredPort?: number
lastDeliveryStatus: string
deliveryLog: string
@@ -660,7 +675,7 @@ const basicServices = ref([
icon: 'My',
bgColor: 'var(--logo-w-bg)',
iconColor: 'var(--tag-blue-text)',
- description: '单实例标准化交付,完成后自动注册进 CloudDM / open-cdm 数据源,统一审核 SQL 上线。',
+ description: '支持单实例和两节点异步主从交付,完成后自动注册进 CloudDM / open-cdm 数据源。',
playbook: 'roles/mysql-deploy',
version: 'v8.0',
status: '可交付',
@@ -669,6 +684,7 @@ const basicServices = ref([
versions: ['MySQL 8.0', 'MySQL 8.4'],
modes: [
{ value: 'single', label: '单实例' },
+ { value: 'replica', label: '一主多从' },
],
specs: ['1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
@@ -816,6 +832,7 @@ const canceling = ref(false)
const deploymentId = ref('')
const deliveryError = ref('')
const deliveredHost = ref('')
+const deliveredHosts = ref([])
const deliveredPort = ref()
const deliveryTargets = ref([])
const selectedTargetId = ref()
@@ -931,10 +948,15 @@ const topologyNodes = computed(() => {
]
}
return activeServiceKey.value === 'mysql'
- ? [
- { name: 'db-tx-021', ip: '10.24.18.21', role: '主节点' },
- { name: 'db-tx-022', ip: '10.24.18.22', role: '从节点' },
- ]
+ ? Array.from({ length: deliveryForm.replicaCount + 1 }, (_, index) => {
+ const selectedName = deliveryForm.targetHosts[index]
+ const host = targetHosts.value.find((item) => item.name === selectedName) || targetHosts.value[index]
+ return {
+ name: host?.name || `等待调度 ${index + 1}`,
+ ip: host?.ip || '由 AWX Inventory 分配',
+ role: index === 0 ? '主节点' : `从节点 ${index}`,
+ }
+ })
: [
{ name: 'ngx-tx-011', ip: '10.24.31.11', role: '主节点' },
{ name: 'ngx-tx-012', ip: '10.24.31.12', role: '备用节点' },
@@ -949,6 +971,7 @@ const runnerPreview = computed(() => {
`component: ${activeService.value?.key || '-'}`,
`instance: ${deliveryForm.instanceName}`,
`topology: ${currentModeLabel.value}`,
+ ...(deliveryForm.mode === 'replica' ? [`replica_count: ${deliveryForm.replicaCount}`] : []),
`limit: ${selectedTargetHostText.value}`,
`${isPostgreSQL.value ? 'data_root' : 'data_disk'}: ${deliveryForm.dataDisk}`,
`resources: ${deliveryForm.spec} / ${storageLabel.value}`,
@@ -968,13 +991,14 @@ const credentialEligible = computed(() => {
const revealedRootCredential = computed(() => revealedCredentials.value.find((item) => item.username === 'root'))
const resultReady = computed(() => !taskStateLoading.value && !running.value && isTerminalDeliveryStatus(lastDeliveryStatus.value))
const selectedTargetHostText = computed(() => {
- if (isPostgreSQL.value && deliveryForm.mode === 'primary_replica') {
+ if ((isPostgreSQL.value && deliveryForm.mode === 'primary_replica') || (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica')) {
return deliveryForm.targetHosts.length ? deliveryForm.targetHosts.join(', ') : '由 AWX Inventory 分配'
}
return deliveryForm.targetHost || '由 AWX Inventory 分配'
})
const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`)
const resultAddress = computed(() => connectionReady.value ? `${deliveredHost.value}:${deliveredPort.value}` : '交付接口未返回地址')
+const replicaEndpoints = computed(() => deliveredHosts.value.slice(1).map((host) => `${host}:${deliveredPort.value || ''}`.replace(/:$/, '')))
const connectionUri = computed(() => {
if (!connectionReady.value) return ''
return isPostgreSQL.value ? `postgresql://postgres@${deliveredHost.value}:${deliveredPort.value}/postgres` : `mysql://root@${deliveredHost.value}:${deliveredPort.value}`
@@ -1134,6 +1158,7 @@ function persistWorkbench() {
deploymentId: deploymentId.value,
deliveryError: deliveryError.value,
deliveredHost: deliveredHost.value,
+ deliveredHosts: deliveredHosts.value,
deliveredPort: deliveredPort.value,
lastDeliveryStatus: lastDeliveryStatus.value,
deliveryLog: deliveryLog.value,
@@ -1197,6 +1222,7 @@ async function restoreWorkbench() {
deploymentId.value = snapshot.deploymentId
deliveryError.value = snapshot.deliveryError
deliveredHost.value = snapshot.deliveredHost
+ deliveredHosts.value = snapshot.deliveredHosts?.length ? snapshot.deliveredHosts : (snapshot.deliveredHost ? [snapshot.deliveredHost] : [])
deliveredPort.value = snapshot.deliveredPort
lastDeliveryStatus.value = snapshot.lastDeliveryStatus
deliveryLog.value = snapshot.deliveryLog
@@ -1209,7 +1235,7 @@ async function restoreWorkbench() {
taskStateLoading.value = true
try {
const data = await deliveryApi.getTask(snapshot.deploymentId)
- deliveredHost.value = data.task.target_host_ip || deliveredHost.value
+ updateDeliveredHosts(data.task)
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
lastDeliveryStatus.value = data.task.status
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
@@ -1304,6 +1330,7 @@ function resetExecutionState() {
canceling.value = false
deploymentId.value = ''
deliveredHost.value = ''
+ deliveredHosts.value = []
deliveredPort.value = undefined
deliveryLog.value = '[ready] 等待创建交付任务...'
seenEventIds.value = new Set()
@@ -1352,6 +1379,24 @@ async function precheck() {
ElMessage.warning('当前没有可用的 AWX 部署模板,请联系平台管理员配置')
return
}
+ if (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica' && targetHosts.value.length < 2) {
+ precheckPassed.value = false
+ ElMessage.error('一主一从至少需要两个可用的 AWX Inventory 主机')
+ return
+ }
+ if (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica') {
+ const requiredHosts = deliveryForm.replicaCount + 1
+ if (targetHosts.value.length < requiredHosts) {
+ precheckPassed.value = false
+ ElMessage.warning(`一主 ${deliveryForm.replicaCount} 从需要 ${requiredHosts} 台不同主机,当前 Inventory 只有 ${targetHosts.value.length} 台`)
+ return
+ }
+ if (deliveryForm.targetHosts.length > 0 && deliveryForm.targetHosts.length !== requiredHosts) {
+ precheckPassed.value = false
+ ElMessage.warning(`请按主节点、从节点顺序选择 ${requiredHosts} 台主机,或清空后由平台自动分配`)
+ return
+ }
+ }
if (!isPostgreSQL.value && deliveryForm.port !== undefined && (deliveryForm.port < 13306 || deliveryForm.port > 13999)) {
ElMessage.warning('手动端口必须在 13306–13999 范围内')
return
@@ -1488,10 +1533,12 @@ function mysqlDeliveryPayload(businessLineId: number) {
namespace: normalizeDNSLabel(currentName.value),
instance_name: normalizeDNSLabel(deliveryForm.instanceName),
mysql_version: mysqlVersionValue(deliveryForm.version),
- topology: 'standalone',
+ topology: deliveryForm.mode === 'replica' ? 'primary_replica' : 'standalone',
+ replica_count: deliveryForm.mode === 'replica' ? deliveryForm.replicaCount : 0,
mysql_port: deliveryForm.port || 0,
data_disk: deliveryForm.dataDisk,
target_host: deliveryForm.targetHost || undefined,
+ target_hosts: deliveryForm.mode === 'replica' && deliveryForm.targetHosts.length ? deliveryForm.targetHosts : undefined,
cpu_milli: resources.cpuMilli,
memory_mi: resources.memoryMi,
storage_gi: deliveryForm.storageGi,
@@ -1549,6 +1596,12 @@ function hostLabel(host: TargetHostOption) {
return host.ip ? `${host.name} · ${host.ip}` : host.name
}
+function updateDeliveredHosts(task: DeliveryTask) {
+ const hosts = (task.target_host_ips || '').split(',').map((host) => host.trim()).filter(Boolean)
+ deliveredHosts.value = hosts.length ? hosts : (task.target_host_ip ? [task.target_host_ip] : deliveredHosts.value)
+ deliveredHost.value = task.target_host_ip || deliveredHost.value || deliveredHosts.value[0] || ''
+}
+
function startTaskPolling(id: string, resetEvents = false) {
stopPolling()
if (resetEvents) seenEventIds.value = new Set()
@@ -1564,7 +1617,7 @@ function stopPolling() {
async function pollTask(id: string) {
try {
const data = await deliveryApi.getTask(id)
- deliveredHost.value = data.task.target_host_ip || deliveredHost.value
+ updateDeliveredHosts(data.task)
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
upsertDeliveryHistory(id, data.task.status, data.task.error_message || '', data.task.created_at)
@@ -2346,6 +2399,13 @@ h4 {
.preview-panel {
position: sticky;
top: 16px;
+ min-width: 0;
+}
+
+.preview-panel > .section-head {
+ margin-bottom: 10px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--line-soft);
}
.section-head,
@@ -2720,14 +2780,22 @@ h4 {
.preview-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
+ gap: 0;
margin-bottom: 12px;
- padding-bottom: 12px;
+ padding: 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.preview-facts div {
min-width: 0;
+ min-height: 42px;
+ padding: 0 12px;
+ border-left: 1px solid var(--line-soft);
+}
+
+.preview-facts div:first-child {
+ padding-left: 0;
+ border-left: 0;
}
.preview-facts span,
@@ -2742,12 +2810,11 @@ h4 {
}
.preview-facts strong {
- overflow: hidden;
color: var(--text-mid);
font-family: var(--mono);
font-size: 11px;
- text-overflow: ellipsis;
- white-space: nowrap;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
}
.config-list {
@@ -2798,7 +2865,63 @@ h4 {
.topology {
display: grid;
gap: 10px;
+ max-height: 300px;
+ margin: 0;
+ overflow-y: auto;
+ padding-right: 4px;
+ align-content: start;
+ scrollbar-gutter: stable;
+}
+
+.topology::-webkit-scrollbar {
+ width: 6px;
+}
+
+.topology::-webkit-scrollbar-thumb {
+ border-radius: 3px;
+ background: var(--line);
+}
+
+.topology-shell {
+ position: relative;
margin-bottom: 14px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--bg-panel-2);
+}
+
+.topology-title {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+ color: var(--text-strong);
+}
+
+.topology-title span {
+ color: var(--text-dim);
+ font-size: 12px;
+}
+
+.topology-connector {
+ position: relative;
+ height: 1px;
+ margin: 0 12px 10px;
+ border-top: 1px dashed var(--accent);
+ opacity: 0.65;
+}
+
+.topology-connector span {
+ position: absolute;
+ top: -9px;
+ left: 50%;
+ padding: 0 8px;
+ transform: translateX(-50%);
+ background: var(--bg-panel-2);
+ color: var(--accent);
+ font-size: 11px;
}
.topology.nodes-1 {
@@ -2813,19 +2936,44 @@ h4 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
+.topology.nodes-4,
+.topology.nodes-5,
+.topology.nodes-6,
+.topology.nodes-7,
+.topology.nodes-8 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
.node-card {
min-width: 0;
- padding: 12px;
+ min-height: 112px;
+ padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-panel-2);
}
+.node-card.primary {
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel-2));
+}
+
+.node-card.replica {
+ border-color: color-mix(in srgb, var(--text-dim) 45%, var(--line));
+}
+
.node-head {
display: flex;
align-items: center;
gap: 8px;
- margin-bottom: 8px;
+ margin-bottom: 9px;
+}
+
+.node-head strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.node-icon {
@@ -2843,6 +2991,7 @@ h4 {
color: var(--text-dim);
font-family: var(--mono);
font-size: 11px;
+ line-height: 1.35;
}
.yaml-preview,
@@ -2859,7 +3008,8 @@ h4 {
}
.yaml-preview {
- min-height: 180px;
+ min-height: 0;
+ max-height: 260px;
margin: 0 0 12px;
padding: 12px 14px;
}
@@ -2871,6 +3021,9 @@ h4 {
}
.form-actions {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line-soft);
justify-content: flex-end;
gap: 8px;
}
From 454df860216a09014b692af5dc9767f3e265731e Mon Sep 17 00:00:00 2001
From: Hungerdream <1710233908@qq.com>
Date: Fri, 31 Jul 2026 18:49:21 +0800
Subject: [PATCH 4/5] =?UTF-8?q?fix(ansible):=20=E4=B8=80=E4=B8=BB=E5=A4=9A?=
=?UTF-8?q?=E4=BB=8E=E4=BB=8E=E5=BA=93=E5=BC=BA=E5=88=B6=E5=8F=AA=E8=AF=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- mysql-instance.cnf.j2 按 mysql_node_role=replica 注入 read-only/super-read-only,
阻止业务直接写入从库导致主从数据分叉(复制线程与复制管理语句不受影响)
- 账号配置任务在从库上临时放开只读、配置完成后回锁,
避免 cnf 只读拒绝 ALTER/CREATE USER;主库与 standalone 不受影响
---
ansible/mysql-deploy-callback.yml | 12 ++++++++++++
ansible/mysql-deploy.yml | 12 ++++++++++++
ansible/templates/mysql-instance.cnf.j2 | 6 ++++++
3 files changed, 30 insertions(+)
diff --git a/ansible/mysql-deploy-callback.yml b/ansible/mysql-deploy-callback.yml
index 01d2526..ae46d03 100644
--- a/ansible/mysql-deploy-callback.yml
+++ b/ansible/mysql-deploy-callback.yml
@@ -452,15 +452,27 @@
EOF
if ! /usr/bin/mysql --defaults-extra-file="$client_file" -e 'SELECT 1' >/dev/null 2>&1; then
cat >"$sql_file" <<'EOF'
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL super_read_only = OFF;
+ SET GLOBAL read_only = OFF;
+ {% endif %}
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'
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL super_read_only = OFF;
+ SET GLOBAL read_only = OFF;
+ {% endif %}
CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}';
ALTER USER 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL read_only = ON;
+ SET GLOBAL super_read_only = ON;
+ {% endif %}
EOF
/usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file"
executable: /bin/bash
diff --git a/ansible/mysql-deploy.yml b/ansible/mysql-deploy.yml
index 143c5f1..d59a54b 100644
--- a/ansible/mysql-deploy.yml
+++ b/ansible/mysql-deploy.yml
@@ -355,15 +355,27 @@
EOF
if ! /usr/bin/mysql --defaults-extra-file="$client_file" -e 'SELECT 1' >/dev/null 2>&1; then
cat >"$sql_file" <<'EOF'
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL super_read_only = OFF;
+ SET GLOBAL read_only = OFF;
+ {% endif %}
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'
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL super_read_only = OFF;
+ SET GLOBAL read_only = OFF;
+ {% endif %}
CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}';
ALTER USER 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
+ {% if topology == 'primary_replica' and inventory_hostname != mysql_primary_host_value %}
+ SET GLOBAL read_only = ON;
+ SET GLOBAL super_read_only = ON;
+ {% endif %}
EOF
/usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file"
executable: /bin/bash
diff --git a/ansible/templates/mysql-instance.cnf.j2 b/ansible/templates/mysql-instance.cnf.j2
index fc3918c..fffb5be 100644
--- a/ansible/templates/mysql-instance.cnf.j2
+++ b/ansible/templates/mysql-instance.cnf.j2
@@ -53,6 +53,12 @@ binlog-expire-logs-seconds={{ mysql_binlog_expire_logs_seconds }} # binlog 过
gtid-mode=ON # 启用 GTID(全局事务标识符)
enforce-gtid-consistency=ON # 强制 GTID 一致性(确保复制安全)
relay-log={{ mysql_binlog_dir }}/relay-bin # 中继日志路径(从库重放 binlog 使用)
+{% if mysql_node_role | default('') == 'replica' %}
+# 从库强制只读:阻止业务直接写入从库导致主从数据分叉
+# 复制线程与 CHANGE REPLICATION SOURCE 等复制管理语句不受影响
+read-only=ON
+super-read-only=ON
+{% endif %}
{% endif %}
# =============================================================================
From 95c3e986115a4d3f1fbbdc64a9fc3a0dfad57770 Mon Sep 17 00:00:00 2001
From: Hungerdream <1710233908@qq.com>
Date: Fri, 31 Jul 2026 18:49:34 +0800
Subject: [PATCH 5/5] =?UTF-8?q?fix(server):=20=E6=98=BE=E5=BC=8F=20target?=
=?UTF-8?q?=5Fhosts=20=E6=97=B6=E4=B8=BB=E6=9C=BA=E6=8C=87=E5=90=91?=
=?UTF-8?q?=E7=94=A8=E6=88=B7=E6=8C=87=E5=AE=9A=E7=9A=84=E4=B8=BB=E8=8A=82?=
=?UTF-8?q?=E7=82=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 显式列表校验通过后令 host = nodes[0],task.TargetHost/IP、台账、
CloudDM 注册与凭据地址不再指向此前默认挑选的空闲主机
- 显式列表解析抽为纯函数 resolvePinnedPrimaryReplicaNodes,
行为与原内联逻辑一致(校验失败终态/单机上限 defer 重试)
- 新增测试覆盖主节点非池首空闲节点及数量/池外/重复/占满场景
---
server/internal/service/delivery.go | 69 +++++++++++++++---------
server/internal/service/delivery_test.go | 29 ++++++++++
2 files changed, 72 insertions(+), 26 deletions(-)
diff --git a/server/internal/service/delivery.go b/server/internal/service/delivery.go
index 25ea5d4..0200da6 100644
--- a/server/internal/service/delivery.go
+++ b/server/internal/service/delivery.go
@@ -335,6 +335,40 @@ func hostTaskCount(occupied []string, name string) int {
return count
}
+// resolvePinnedPrimaryReplicaNodes 按用户显式指定的有序 target_hosts 解析 primary_replica 节点列表,
+// 首节点为主节点。返回值:节点列表、参数校验失败原因(终态)、延后调度错误(defer: 前缀,可重试)。
+func resolvePinnedPrimaryReplicaNodes(hosts []targetHost, requested []string, occupied []string, limit int, nodeCount int64, pinnedPrimary string) ([]*targetHost, string, error) {
+ if int64(len(requested)) != nodeCount {
+ return nil, fmt.Sprintf("primary_replica requires %d target_hosts, got %d", nodeCount, len(requested)), nil
+ }
+ nodes := make([]*targetHost, 0, len(requested))
+ seen := map[string]struct{}{}
+ for index, requestedHost := range requested {
+ var selected *targetHost
+ for i := range hosts {
+ if hosts[i].Name == requestedHost {
+ selected = &hosts[i]
+ break
+ }
+ }
+ if selected == nil {
+ return nil, fmt.Sprintf("target_hosts[%d] %q is not in the candidate host pool", index, requestedHost), nil
+ }
+ if _, exists := seen[selected.Name]; exists {
+ return nil, fmt.Sprintf("target_hosts contains duplicate host %q", selected.Name), nil
+ }
+ if hostTaskCount(occupied, selected.Name) >= max(limit, 1) {
+ return nil, "", fmt.Errorf("defer: pinned host %s reached the per-host instance limit %d", selected.Name, max(limit, 1))
+ }
+ seen[selected.Name] = struct{}{}
+ nodes = append(nodes, selected)
+ }
+ if pinnedPrimary != "" && nodes[0].Name != pinnedPrimary {
+ return nil, "target_host must match the first primary_replica target_hosts entry", nil
+ }
+ return nodes, "", nil
+}
+
func topologyNodeCount(input MySQLDeliveryInput) int64 {
if input.Topology == "primary_replica" {
replicas := input.ReplicaCount
@@ -1757,34 +1791,17 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
nodes := []*targetHost{host}
if nodeCount > 1 {
if len(payload.TargetHosts) > 0 {
- if int64(len(payload.TargetHosts)) != nodeCount {
- return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("primary_replica requires %d target_hosts, got %d", nodeCount, len(payload.TargetHosts)))
+ pinned, validationMsg, deferErr := resolvePinnedPrimaryReplicaNodes(meta.Hosts, payload.TargetHosts, occupied, limit, nodeCount, payload.TargetHost)
+ if deferErr != nil {
+ return deferErr
}
- nodes = nodes[:0]
- seen := map[string]struct{}{}
- for index, requestedHost := range payload.TargetHosts {
- var selected *targetHost
- for i := range meta.Hosts {
- if meta.Hosts[i].Name == requestedHost {
- selected = &meta.Hosts[i]
- break
- }
- }
- if selected == nil {
- return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("target_hosts[%d] %q is not in the candidate host pool", index, requestedHost))
- }
- if _, exists := seen[selected.Name]; exists {
- return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("target_hosts contains duplicate host %q", selected.Name))
- }
- if hostTaskCount(occupied, selected.Name) >= max(limit, 1) {
- return fmt.Errorf("defer: pinned host %s reached the per-host instance limit %d", selected.Name, max(limit, 1))
- }
- seen[selected.Name] = struct{}{}
- nodes = append(nodes, selected)
- }
- if payload.TargetHost != "" && nodes[0].Name != payload.TargetHost {
- return s.failInTransaction(tx, &task, model.TaskValidationFailed, "target_host must match the first primary_replica target_hosts entry")
+ if validationMsg != "" {
+ return s.failInTransaction(tx, &task, model.TaskValidationFailed, validationMsg)
}
+ // 显式列表以首节点为主节点:覆盖此前默认挑选的空闲主机,
+ // 避免 task.TargetHost/IP 与台账、凭据指向未部署节点。
+ nodes = pinned
+ host = nodes[0]
} else {
for i := range meta.Hosts {
candidate := &meta.Hosts[i]
diff --git a/server/internal/service/delivery_test.go b/server/internal/service/delivery_test.go
index 6a5c2ef..a197443 100644
--- a/server/internal/service/delivery_test.go
+++ b/server/internal/service/delivery_test.go
@@ -138,6 +138,35 @@ func TestFirstFreeHost(t *testing.T) {
}
}
+func TestResolvePinnedPrimaryReplicaNodes(t *testing.T) {
+ pool := []targetHost{{Name: "node-a", IP: "10.0.0.1"}, {Name: "node-b", IP: "10.0.0.2"}, {Name: "node-c", IP: "10.0.0.3"}}
+
+ // 主节点不是池首空闲节点(firstFreeHost 会选 node-a):首节点必须以用户列表为准。
+ nodes, validationMsg, err := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-c", "node-a"}, nil, 4, 2, "")
+ if err != nil || validationMsg != "" {
+ t.Fatalf("unexpected rejection: msg=%q err=%v", validationMsg, err)
+ }
+ if nodes[0].Name != "node-c" || nodes[0].IP != "10.0.0.3" || nodes[1].Name != "node-a" {
+ t.Fatalf("primary must follow the user-specified order, got %+v", nodes)
+ }
+
+ if _, msg, _ := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-a"}, nil, 4, 2, ""); msg == "" {
+ t.Fatal("host count mismatch was accepted")
+ }
+ if _, msg, _ := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-a", "node-x"}, nil, 4, 2, ""); msg == "" {
+ t.Fatal("host outside the pool was accepted")
+ }
+ if _, msg, _ := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-a", "node-a"}, nil, 4, 2, ""); msg == "" {
+ t.Fatal("duplicate host was accepted")
+ }
+ if _, msg, _ := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-b", "node-c"}, nil, 4, 2, "node-a"); msg == "" {
+ t.Fatal("target_host mismatching the first entry was accepted")
+ }
+ if _, _, err := resolvePinnedPrimaryReplicaNodes(pool, []string{"node-a", "node-b"}, []string{"node-b"}, 1, 2, ""); err == nil {
+ t.Fatal("saturated pinned host must defer scheduling")
+ }
+}
+
func TestAllocatePort(t *testing.T) {
if port, err := allocatePort(0, nil); err != nil || port != mysqlPortPoolStart {
t.Fatalf("expected first pool port %d, got %d err=%v", mysqlPortPoolStart, port, err)