Merge pull request #129 from Hungerdream/feat/mysql-single-root-account

feat: 调整 MySQL 交付账号 (#108, #97)
This commit is contained in:
ztkkOip
2026-07-30 15:45:50 +08:00
committed by GitHub
5 changed files with 113 additions and 61 deletions
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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
-1
View File
@@ -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 {
+96 -33
View File
@@ -788,6 +788,7 @@ const taskStateLoading = ref(false)
const credentialRevealed = ref(false)
const credentialDismissed = ref(false)
const credentialRevealing = ref(false)
const credentialAvailable = ref<boolean>()
const revealedCredentials = ref<DeploymentCredential[]>([])
const deliveryHistory = ref<DeliveryHistoryItem[]>(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<string, string[]> = {
}
const steps = ref<DeliveryStep[]>([])
const deliveryStageStepIndex: Record<string, number> = {
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<string, StepState> = {
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
})
}
+9 -17
View File
@@ -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)
}