diff --git a/ansible/mysql-deploy-callback.yml b/ansible/mysql-deploy-callback.yml index befff5f..945d3f9 100644 --- a/ansible/mysql-deploy-callback.yml +++ b/ansible/mysql-deploy-callback.yml @@ -65,7 +65,6 @@ # --- secrets (prefer launch extra_vars; fall back to AWX credential-injected env) --- mysql_root_password_value: "{{ mysql_root_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD'), true) }}" - mysql_admin_password_value: "{{ mysql_admin_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD'), true) }}" # --- platform callback --- delivery_callback_url_value: "{{ delivery_callback_url | default('') }}" @@ -110,7 +109,6 @@ - (mysql_storage_gb_value | int) <= 2000 - (mysql_lower_case_table_names | int) in [0, 1] - mysql_root_password_value | length >= 16 - - mysql_admin_password_value | length >= 16 fail_msg: >- Delivery parameters out of the supported target-state whitelist (topology / version-package-map / port pool 13306-13999 / memory 2-64G / storage 20-2000G). @@ -453,9 +451,9 @@ /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <"$sql_file" fi cat >"$sql_file" <<'EOF' - CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + 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; EOF /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" @@ -554,4 +552,5 @@ - name: Restart MySQL delivery instance ansible.builtin.systemd_service: name: "mysql-delivery@{{ mysql_instance }}.service" + daemon_reload: true state: restarted diff --git a/ansible/mysql-deploy.yml b/ansible/mysql-deploy.yml index a256b6f..ad5f69d 100644 --- a/ansible/mysql-deploy.yml +++ b/ansible/mysql-deploy.yml @@ -65,7 +65,6 @@ # --- secrets (prefer launch extra_vars; fall back to AWX credential-injected env) --- mysql_root_password_value: "{{ mysql_root_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD'), true) }}" - mysql_admin_password_value: "{{ mysql_admin_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD'), true) }}" pre_tasks: - name: Validate delivery parameters @@ -86,7 +85,6 @@ - (mysql_storage_gb_value | int) <= 2000 - (mysql_lower_case_table_names | int) in [0, 1] - mysql_root_password_value | length >= 16 - - mysql_admin_password_value | length >= 16 fail_msg: >- Delivery parameters out of the supported target-state whitelist (topology / version-package-map / port pool 13306-13999 / memory 2-64G / storage 20-2000G). @@ -356,9 +354,9 @@ /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <"$sql_file" fi cat >"$sql_file" <<'EOF' - CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + 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; EOF /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" @@ -385,4 +383,5 @@ - name: Restart MySQL delivery instance ansible.builtin.systemd_service: name: "mysql-delivery@{{ mysql_instance }}.service" + daemon_reload: true state: restarted diff --git a/frontend/src/api/delivery.ts b/frontend/src/api/delivery.ts index fd8c1dd..668aecb 100644 --- a/frontend/src/api/delivery.ts +++ b/frontend/src/api/delivery.ts @@ -45,7 +45,6 @@ export interface CreateMySQLDeliveryPayload { binlog_expire_logs_seconds?: number max_binlog_size?: string mysql_root_password?: string - mysql_admin_password?: string } export interface DeliveryTask { diff --git a/frontend/src/views/service/Catalog.vue b/frontend/src/views/service/Catalog.vue index 68e3e05..65c0184 100644 --- a/frontend/src/views/service/Catalog.vue +++ b/frontend/src/views/service/Catalog.vue @@ -788,6 +788,7 @@ const taskStateLoading = ref(false) const credentialRevealed = ref(false) const credentialDismissed = ref(false) const credentialRevealing = ref(false) +const credentialAvailable = ref() const revealedCredentials = ref([]) const deliveryHistory = ref(loadDeliveryHistory()) let workbenchReady = false @@ -817,7 +818,7 @@ const canManageRollback = computed(() => authStore.isAdmin && lastDeliveryStatus const canRetryCloudDM = computed(() => deliveryRegisterFailed.value && Boolean(deploymentId.value)) const deliveryForm = reactive({ - instanceName: `mysql-${currentName.value}-billing-02`, + instanceName: generateInstanceName(activeServiceKey.value, currentName.value), version: 'MySQL 8.0', mode: 'single', spec: '1C / 2G', @@ -852,6 +853,13 @@ const collationMap: Record = { } const steps = ref([]) +const deliveryStageStepIndex: Record = { + precheck: 0, + install: 1, + configure: 2, + healthcheck: 3, + register: 4, +} const activeService = computed(() => basicServices.value.find((service) => service.key === activeServiceKey.value && !service.disabled)) const selectedTarget = computed(() => deliveryTargets.value.find((target) => target.id === selectedTargetId.value)) @@ -912,7 +920,10 @@ const runnerPreview = computed(() => { ].join('\n') }) const connectionReady = computed(() => Boolean(deliveredHost.value && deliveredPort.value)) -const credentialEligible = computed(() => connectionReady.value && ['finished', 'register_failed'].includes(lastDeliveryStatus.value)) +const credentialEligible = computed(() => { + if (!connectionReady.value || !['finished', 'register_failed'].includes(lastDeliveryStatus.value)) return false + return credentialAvailable.value !== false +}) const revealedRootCredential = computed(() => revealedCredentials.value.find((item) => item.username === 'root')) const resultReady = computed(() => !taskStateLoading.value && !running.value && isTerminalDeliveryStatus(lastDeliveryStatus.value)) const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`) @@ -964,7 +975,7 @@ const deliveryStateClass = computed(() => { }) watch(currentName, (name) => { - deliveryForm.instanceName = `${activeServiceKey.value === 'mysql' ? 'mysql' : 'nginx'}-${name}-billing-02` + deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, name) }) watch(selectedTargetId, () => { @@ -1116,8 +1127,11 @@ async function restoreWorkbench() { restoringWorkbench = true Object.assign(deliveryForm, snapshot.form) + if (!snapshot.deploymentId) { + deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, currentName.value) + } deliveryForm.rootPassword = generateRootPassword() - precheckPassed.value = snapshot.precheckPassed + precheckPassed.value = snapshot.deploymentId ? snapshot.precheckPassed : false running.value = snapshot.running deliveryDone.value = snapshot.deliveryDone deliveryFailed.value = snapshot.deliveryFailed @@ -1130,6 +1144,7 @@ async function restoreWorkbench() { steps.value = snapshot.steps?.length ? snapshot.steps : defaultSteps() credentialRevealed.value = false credentialDismissed.value = Boolean(snapshot.deploymentId) + credentialAvailable.value = undefined if (snapshot.deploymentId) { taskStateLoading.value = true @@ -1138,8 +1153,10 @@ async function restoreWorkbench() { deliveredHost.value = data.task.target_host_ip || deliveredHost.value deliveredPort.value = data.task.mysql_port || deliveredPort.value lastDeliveryStatus.value = data.task.status + credentialAvailable.value = data.task.credential_available deliveryError.value = data.task.error_message || deliveryError.value - seenEventIds.value = new Set(data.events.map((event) => event.id)) + seenEventIds.value = new Set() + applyTaskEvents(data.events, false) applyDeliveryStatus(data.task.status, data.task.error_message || '') if (isTerminalDeliveryStatus(data.task.status)) { activeView.value = 'result' @@ -1167,8 +1184,7 @@ async function restoreWorkbench() { function hydrateServiceDefaults() { const service = activeService.value if (!service) return - const prefix = service.key === 'mysql' ? 'mysql' : 'nginx' - deliveryForm.instanceName = `${prefix}-${currentName.value}-billing-02` + deliveryForm.instanceName = generateInstanceName(service.key, currentName.value) deliveryForm.version = service.versions[0] || '' deliveryForm.mode = service.modes[1]?.value || service.modes[0]?.value || 'single' deliveryForm.spec = service.key === 'mysql' @@ -1220,6 +1236,7 @@ function resetExecutionState() { deliveryFailed.value = false credentialRevealed.value = false credentialDismissed.value = false + credentialAvailable.value = undefined revealedCredentials.value = [] deliveryError.value = '' lastDeliveryStatus.value = '' @@ -1228,6 +1245,7 @@ function resetExecutionState() { deliveredHost.value = '' deliveredPort.value = undefined deliveryLog.value = '[ready] 等待创建交付任务...' + seenEventIds.value = new Set() steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' })) } @@ -1345,11 +1363,12 @@ async function createTask() { try { const task = await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId)) deploymentId.value = task.id + credentialAvailable.value = task.credential_available deliveryForm.rootPassword = '' upsertDeliveryHistory(task.id, task.status, task.error_message || '', task.created_at) deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}` applyDeliveryStatus(task.status, '') - startTaskPolling(task.id) + startTaskPolling(task.id, true) persistWorkbench() ElMessage.success('交付任务已创建') } catch (error) { @@ -1402,9 +1421,8 @@ function mysqlDeliveryPayload(businessLineId: number) { long_query_time: deliveryForm.longQueryTime, binlog_expire_logs_seconds: deliveryForm.binlogExpireSeconds, max_binlog_size: deliveryForm.maxBinlogSize, - // root 密码以表单为准;后端要求 root/admin 成对传入,平台管理账号密码随机生成 + // 交付只提供一个允许远程访问的 root 管理员账号。 mysql_root_password: deliveryForm.rootPassword, - mysql_admin_password: generateRootPassword(), } return payload } @@ -1423,9 +1441,9 @@ function hostLabel(host: TargetHostOption) { return host.ip ? `${host.name} · ${host.ip}` : host.name } -function startTaskPolling(id: string) { +function startTaskPolling(id: string, resetEvents = false) { stopPolling() - seenEventIds.value = new Set() + if (resetEvents) seenEventIds.value = new Set() pollTask(id) pollTimer.value = window.setInterval(() => pollTask(id), 3000) } @@ -1440,6 +1458,7 @@ async function pollTask(id: string) { const data = await deliveryApi.getTask(id) deliveredHost.value = data.task.target_host_ip || deliveredHost.value deliveredPort.value = data.task.mysql_port || deliveredPort.value + credentialAvailable.value = data.task.credential_available upsertDeliveryHistory(id, data.task.status, data.task.error_message || '', data.task.created_at) applyTaskEvents(data.events) applyDeliveryStatus(data.task.status, data.task.error_message || '') @@ -1453,14 +1472,30 @@ async function pollTask(id: string) { } } -function applyTaskEvents(events: TaskEvent[]) { +function applyTaskEvents(events: TaskEvent[], writeLogs = true) { events.forEach((event) => { if (seenEventIds.value.has(event.id)) return seenEventIds.value.add(event.id) - appendLog(`[${event.to_state}] ${event.message}`) + applyStageEvent(event) + if (writeLogs) appendLog(`[${event.to_state}] ${event.message}`) }) } +function applyStageEvent(event: TaskEvent) { + const stage = String(event.stage || '').toLowerCase() + const eventStatus = String(event.event_status || '').toLowerCase() + const stepIndex = deliveryStageStepIndex[stage] + if (stepIndex === undefined) return + const stateMap: Record = { + running: 'running', + success: 'done', + failed: 'failed', + } + const state = stateMap[eventStatus] + if (!state) return + setDeliveryStep(stepIndex, state) +} + function appendLog(message: unknown) { if (!message) return deliveryLog.value += `\n${String(message)}` @@ -1479,23 +1514,41 @@ async function copyText(value: string, message: string) { function applyDeliveryStatus(status: string, message: string) { lastDeliveryStatus.value = status if (message) appendLog(message) - if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling', 'rollback_pending', 'rolling_back'].includes(status)) { + if (['pending', 'validating', 'dispatching', 'running'].includes(status)) { running.value = true - markStepRunning() + if (!steps.value.slice(0, 5).some((step) => step.state !== 'pending')) { + setDeliveryStep(0, 'running') + } + return + } + if (status === 'registering') { + running.value = true + setDeliveryStep(4, 'running') + return + } + if (status === 'canceling') { + running.value = true + return + } + if (['rollback_pending', 'rolling_back'].includes(status)) { + running.value = true + setDeliveryStep(5, 'running') return } if (status === 'finished') { running.value = false deliveryDone.value = true deliveryFailed.value = false - steps.value = steps.value.map((step) => ({ ...step, state: 'done' })) + steps.value = steps.value.map((step, index) => ( + index < 5 ? { ...step, state: 'done' } : step + )) return } if (status === 'rolled_back') { running.value = false deliveryDone.value = false deliveryFailed.value = true - steps.value = steps.value.map((step) => ({ ...step, state: 'done' })) + setDeliveryStep(5, 'done') return } if (['execution_failed', 'validation_failed', 'register_failed', 'rollback_failed', 'rollback_acknowledged', 'canceled'].includes(status)) { @@ -1503,7 +1556,11 @@ function applyDeliveryStatus(status: string, message: string) { deliveryDone.value = false deliveryFailed.value = true deliveryError.value = message || deliveryError.value - markCurrentStepFailed() + if (status === 'validation_failed') setDeliveryStep(0, 'failed') + else if (status === 'register_failed') setDeliveryStep(4, 'failed') + else if (status === 'rollback_failed') setDeliveryStep(5, 'failed') + else if (status === 'rollback_acknowledged') setDeliveryStep(5, 'done') + else markCurrentStepFailed() } } @@ -1511,7 +1568,8 @@ async function loadDeliveryTargets() { targetsLoading.value = true try { deliveryTargets.value = await deliveryApi.listTargets() - const preferred = deliveryTargets.value.find((target) => !/callback|rollback/i.test(target.name)) + const preferred = deliveryTargets.value.find((target) => /callback/i.test(target.name)) + || deliveryTargets.value.find((target) => !/rollback/i.test(target.name)) selectedTargetId.value = preferred?.id || deliveryTargets.value[0]?.id } catch (error) { ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败') @@ -1533,6 +1591,7 @@ function setupCredentialPreview() { deliveredPort.value = 13306 credentialRevealed.value = false credentialDismissed.value = false + credentialAvailable.value = true revealedCredentials.value = [] upsertDeliveryHistory(deploymentId.value, 'finished', '', new Date().toISOString()) if (deliveryHistory.value.length < 3) { @@ -1614,6 +1673,17 @@ function normalizeDNSLabel(value: string) { return normalized || 'default' } +function generateInstanceName(serviceKey: string, businessLineName: string) { + const prefix = serviceKey === 'mysql' ? 'mysql' : 'nginx' + // 限长业务线段,确保 63 字符截断不会吃掉随机后缀(prefix 5 + 连字符 2 + 后缀 6 = 13) + const businessLine = normalizeDNSLabel(businessLineName).slice(0, 50).replace(/-+$/g, '') + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' + const values = new Uint32Array(6) + crypto.getRandomValues(values) + const suffix = Array.from(values, (value) => alphabet[value % alphabet.length]).join('') + return normalizeDNSLabel(`${prefix}-${businessLine}-${suffix}`) +} + function generateRootPassword() { const groups = [ 'ABCDEFGHJKLMNPQRSTUVWXYZ', @@ -1657,13 +1727,7 @@ function regenerateRootPassword() { } function regenerateInstanceName() { - const prefix = activeServiceKey.value === 'mysql' ? 'mysql' : 'service' - const businessLine = normalizeDNSLabel(currentName.value) - const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' - const values = new Uint32Array(6) - crypto.getRandomValues(values) - const suffix = Array.from(values, (value) => alphabet[value % alphabet.length]).join('') - deliveryForm.instanceName = normalizeDNSLabel(`${prefix}-${businessLine}-${suffix}`) + deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, currentName.value) precheckPassed.value = false ElMessage.success('已生成新的实例名称,请执行预检查确认可用') } @@ -1679,7 +1743,7 @@ async function revealCredential() { host: deliveredHost.value || '10.24.18.21', port: deliveredPort.value || 13306, username: 'root', - account_host: 'localhost', + account_host: '%', password: deliveryForm.rootPassword, }] } else { @@ -1925,12 +1989,11 @@ async function releaseRollback() { } } -function markStepRunning() { - const index = steps.value.findIndex((step) => step.state === 'pending' || step.state === 'running') - if (index < 0) return +function setDeliveryStep(index: number, state: StepState) { + if (index < 0 || index >= steps.value.length) return steps.value = steps.value.map((step, stepIndex) => { - if (stepIndex < index) return { ...step, state: 'done' } - if (stepIndex === index) return { ...step, state: 'running' } + if (stepIndex < index && step.state !== 'failed') return { ...step, state: 'done' } + if (stepIndex === index) return { ...step, state } return step }) } diff --git a/server/internal/service/delivery.go b/server/internal/service/delivery.go index 2d51770..d92f8b5 100644 --- a/server/internal/service/delivery.go +++ b/server/internal/service/delivery.go @@ -65,7 +65,6 @@ type MySQLDeliveryInput struct { BinlogExpireLogsSeconds int64 `json:"binlog_expire_logs_seconds"` MaxBinlogSize string `json:"max_binlog_size"` MySQLRootPassword string `json:"mysql_root_password"` - MySQLAdminPassword string `json:"mysql_admin_password"` } type deliveryPayload struct { @@ -575,23 +574,18 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin return nil, false, err } credentialInput := map[string]string{ - "root@localhost": strings.TrimSpace(input.MySQLRootPassword), - "xinfra_admin@%": strings.TrimSpace(input.MySQLAdminPassword), + "root@%": strings.TrimSpace(input.MySQLRootPassword), } - hasCredentialInput := credentialInput["root@localhost"] != "" || credentialInput["xinfra_admin@%"] != "" + hasCredentialInput := credentialInput["root@%"] != "" if hasCredentialInput { - if credentialInput["root@localhost"] == "" || credentialInput["xinfra_admin@%"] == "" { - return nil, false, fmt.Errorf("mysql_root_password and mysql_admin_password must be provided together") + if len(credentialInput["root@%"]) < 16 { + return nil, false, fmt.Errorf("mysql root password must be at least 16 characters") } - if len(credentialInput["root@localhost"]) < 16 || len(credentialInput["xinfra_admin@%"]) < 16 { - return nil, false, fmt.Errorf("mysql passwords must be at least 16 characters") - } - if !mysqlPasswordPattern.MatchString(credentialInput["root@localhost"]) || !mysqlPasswordPattern.MatchString(credentialInput["xinfra_admin@%"]) { + if !mysqlPasswordPattern.MatchString(credentialInput["root@%"]) { return nil, false, fmt.Errorf("mysql passwords may only contain letters and digits") } } input.MySQLRootPassword = "" - input.MySQLAdminPassword = "" var existing model.DeliveryTask if err := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err == nil { @@ -990,14 +984,12 @@ func (s *DeliveryService) deploymentCredentialVars(ctx context.Context, taskID s return nil, err } switch item.Username + "@" + item.AccountHost { - case "root@localhost": + case "root@%", "root@localhost": values["mysql_root_password"] = password - case "xinfra_admin@%": - values["mysql_admin_password"] = password } } - if values["mysql_root_password"] == "" || values["mysql_admin_password"] == "" { - return nil, fmt.Errorf("deployment credentials are missing for task %s", taskID) + if values["mysql_root_password"] == "" { + return nil, fmt.Errorf("root deployment credential is missing for task %s", taskID) } return values, nil } @@ -2054,7 +2046,7 @@ func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) er } var credential model.DeploymentCredential if err := s.db.WithContext(ctx). - Where("task_id = ? AND username = ? AND account_host = ? AND status IN ?", taskID, "root", "localhost", []string{"pending", "available"}). + Where("task_id = ? AND username = ? AND account_host IN ? AND status IN ?", taskID, "root", []string{"%", "localhost"}, []string{"pending", "available"}). First(&credential).Error; err != nil { return fmt.Errorf("CloudDM registration requires the MySQL root credential: %w", err) }