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/
|
RUN CGO_ENABLED=1 GOOS=linux go build -o opsxsql ./cmd/server/
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
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
|
WORKDIR /app
|
||||||
COPY --from=build /app/opsxsql .
|
COPY --from=build /app/opsxsql .
|
||||||
COPY --from=build /app/cmd/server/seed.go /app/
|
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)
|
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{
|
task := &ghost.GhOstTask{
|
||||||
Host: inst.Host,
|
Host: inst.Host,
|
||||||
Port: inst.Port,
|
Port: inst.Port,
|
||||||
User: inst.Username,
|
User: inst.Username,
|
||||||
Password: inst.Password,
|
Password: password,
|
||||||
Database: database,
|
Database: database,
|
||||||
Table: table,
|
Table: table,
|
||||||
Alter: alter,
|
Alter: alter,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sql-platform/internal/ghost"
|
||||||
"sql-platform/internal/middleware"
|
"sql-platform/internal/middleware"
|
||||||
"sql-platform/internal/model"
|
"sql-platform/internal/model"
|
||||||
|
|
||||||
@@ -544,6 +545,39 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// MySQL/PG execution
|
// 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)
|
db, err := getDBConnection(&instance, ticket.TargetDatabase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execErr = fmt.Sprintf("DB connection failed: %v", err)
|
execErr = fmt.Sprintf("DB connection failed: %v", err)
|
||||||
@@ -606,6 +640,7 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executionDone:
|
||||||
// Update final status
|
// Update final status
|
||||||
if execErr != "" {
|
if execErr != "" {
|
||||||
failStatus := "failed"
|
failStatus := "failed"
|
||||||
@@ -632,6 +667,55 @@ func truncateStr(s string, maxLen int) string {
|
|||||||
return s[:maxLen] + "..."
|
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
|
// POST /api/v1/tickets/:id/cancel
|
||||||
func CancelTicket(c *gin.Context) {
|
func CancelTicket(c *gin.Context) {
|
||||||
|
|||||||
Reference in new issue
Block a user