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:
1 parent
bc2092a980
commit
b2b689f791
3 files changed
+96
-2
No files matched your search
+5
-1
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user