diff --git a/backend/Dockerfile b/backend/Dockerfile index 50da62c..80f1440 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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/ diff --git a/backend/internal/handler/ghost.go b/backend/internal/handler/ghost.go index 5bc20fc..541ca68 100644 --- a/backend/internal/handler/ghost.go +++ b/backend/internal/handler/ghost.go @@ -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, diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 6e3c21a..a2f9343 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -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) {