feat: 工单执行集成gh-ost通道 + Dockerfile安装gh-ost + 密码解密修复

- ExecuteTicket: 检测 ghost_enabled + ALTER TABLE → 走 gh-ost 同步执行
- parseAlterTable: 解析 ALTER TABLE 提取表名和 alter 子句(支持反引号/db.table)
- buildGhOstTask: 修复密码传递(加密→解密后传给 gh-ost 命令行)
- Dockerfile: 运行阶段下载 gh-ost v1.1.6 Linux amd64 二进制
- ghost_enabled=false 默认关闭,工单流程正常走普通通道
- 端到端验证: ALTER TABLE+ghost_enabled=true → gh-ost; INSERT/关闭 → 普通
This commit is contained in:
logan committed 2026-06-01 01:47:08 +08:00
1 parent bc2092a980
commit b2b689f791
3 files changed
+96 -2

No files matched your search

+5 -1
View File
@@ -7,7 +7,11 @@ COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o opsxsql ./cmd/server/
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates sqlite3 && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl && apt-get clean && rm -rf /var/lib/apt/lists/*
# Install gh-ost for online DDL
RUN GH_OST_VERSION=1.1.6 && \
curl -fsSL "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost_${GH_OST_VERSION}_linux_amd64.tar.gz" \
| tar xz -C /usr/local/bin gh-ost && chmod +x /usr/local/bin/gh-ost
WORKDIR /app
COPY --from=build /app/opsxsql .
COPY --from=build /app/cmd/server/seed.go /app/
+7 -1
View File
@@ -353,11 +353,17 @@ func buildGhOstTask(instanceID int, database, table, alter string) (*ghost.GhOst
return nil, fmt.Errorf("gh-ost only supports MySQL instances, got %s", inst.DBType)
}
// Decrypt password for gh-ost command line
password := inst.Password
if dec, err := decryptPassword(strings.TrimPrefix(inst.Password, "enc:")); err == nil {
password = dec
}
task := &ghost.GhOstTask{
Host: inst.Host,
Port: inst.Port,
User: inst.Username,
Password: inst.Password,
Password: password,
Database: database,
Table: table,
Alter: alter,
+84
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"sql-platform/internal/ghost"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -544,6 +545,39 @@ func ExecuteTicket(c *gin.Context) {
}
} else {
// MySQL/PG execution
// ── Ghost/OSC channel: if ghost_enabled and SQL is ALTER TABLE → use gh-ost ──
if instance.DBType == "mysql" && isGhostEnabled() {
upperSQL := strings.TrimSpace(strings.ToUpper(ticket.SQLContent))
if strings.HasPrefix(upperSQL, "ALTER TABLE") {
// Parse ALTER TABLE → extract table name and alter clause
tableName, alterClause, parseErr := parseAlterTable(ticket.SQLContent)
if parseErr != nil {
execErr = fmt.Sprintf("ALTER TABLE 解析失败: %v", parseErr)
} else {
ghostTask, buildErr := buildGhOstTask(int(instance.ID), ticket.TargetDatabase, tableName, alterClause)
if buildErr != nil {
execErr = fmt.Sprintf("gh-ost 任务构建失败: %v", buildErr)
} else {
ghostTask.Execute = true
applyGhostSettings(ghostTask)
// Apply executor timeout
execTimeout := getSettingInt("executor_timeout", 300)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
defer cancel()
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
if ghErr != nil {
execErr = fmt.Sprintf("gh-ost 执行失败: %s\n%s", result.Error, ghErr.Error())
} else {
execResult = fmt.Sprintf("[gh-ost OK] %s\n%s", truncateStr(ticket.SQLContent, 80), result.Output)
}
// Skip normal execution path
goto executionDone
}
}
}
}
db, err := getDBConnection(&instance, ticket.TargetDatabase)
if err != nil {
execErr = fmt.Sprintf("DB connection failed: %v", err)
@@ -606,6 +640,7 @@ func ExecuteTicket(c *gin.Context) {
}
}
executionDone:
// Update final status
if execErr != "" {
failStatus := "failed"
@@ -632,6 +667,55 @@ func truncateStr(s string, maxLen int) string {
return s[:maxLen] + "..."
}
// parseAlterTable parses "ALTER TABLE [db.]table ..." → returns (tableName, alterClause, error)
// Handles backtick-quoted names: ALTER TABLE `my_db`.`my_table` ADD COLUMN ...
func parseAlterTable(sql string) (string, string, error) {
// Normalize whitespace
s := strings.TrimSpace(sql)
upper := strings.ToUpper(s)
// Strip leading ALTER TABLE
if !strings.HasPrefix(upper, "ALTER TABLE") {
return "", "", fmt.Errorf("not an ALTER TABLE statement")
}
rest := strings.TrimSpace(s[len("ALTER TABLE"):])
// Parse table name (may include db.table or `db`.`table`)
tableName := ""
alterStart := 0
if rest[0] == '`' {
// Backtick-quoted
end := strings.Index(rest[1:], "`")
if end < 0 {
return "", "", fmt.Errorf("unclosed backtick in table name")
}
tableName = rest[1 : end+1]
alterStart = end + 2
} else {
// Unquoted — find first space
idx := strings.Index(rest, " ")
if idx < 0 {
return "", "", fmt.Errorf("no alter clause found")
}
tableName = rest[:idx]
alterStart = idx
}
// Strip database prefix if present (db.table → table)
if parts := strings.SplitN(tableName, ".", 2); len(parts) == 2 {
tableName = strings.Trim(parts[1], "`")
} else {
tableName = strings.Trim(tableName, "`")
}
alterClause := strings.TrimSpace(rest[alterStart:])
if alterClause == "" {
return "", "", fmt.Errorf("empty alter clause")
}
return tableName, alterClause, nil
}
// POST /api/v1/tickets/:id/cancel
func CancelTicket(c *gin.Context) {