Files
Robert Fairburn 68d9a37665 Cleanup an edge case sql error in migration cleanup (#48179)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved SQL generation for certain migration updates to avoid a
self-referencing subquery issue.
* Updated the generated query flow to select the target row first, then
apply the update more reliably.
* Aligned the version lookup query formatting with the revised condition
handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-24 12:20:37 -05:00

945 lines
28 KiB
Go

package main
import (
"bufio"
"context"
"errors"
"fmt"
"log"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
configpkg "github.com/fleetdm/fleet/v4/server/config"
commonmysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
gomysql "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"golang.org/x/term"
)
const (
exitGeneral = 1
exitDryRun = 2
exitApply = 3
tableStatusName = "migration_status_tables"
dataStatusName = "migration_status_data"
)
var (
tsRE = regexp.MustCompile(`^(\d{14})_`)
migrationDirs = []string{
"server/datastore/mysql/migrations/tables",
"server/datastore/mysql/migrations/data",
}
)
type migrationRename struct {
oldVersionID int64
newVersionID int64
migrationType string
oldPath string
newPath string
commitSHA string
}
type tableRow struct {
ID int64 `db:"id"`
VersionID int64 `db:"version_id"`
IsApplied bool `db:"is_applied"`
}
type sqlStatements struct {
tableName string
versionIDRemappings [][2]int64
}
type options struct {
checkout string
branch string
output string
dryRun bool
apply bool
verbose bool
dbHost string
dbPort int
dbName string
dbUser string
dbPassword string
tlsMode string
tlsCA string
tlsCert string
tlsKey string
}
func main() {
log.SetFlags(0)
opts := options{}
rootCmd := newRootCmd(&opts)
configManager := configpkg.NewManager(rootCmd)
hideUnneededFleetConfigFlags(rootCmd)
rootCmd.Run = func(cmd *cobra.Command, args []string) {
if err := run(cmd.Context(), configManager, opts); err != nil {
var exitErr exitError
if errors.As(err, &exitErr) {
if exitErr.message != "" {
fmt.Fprintln(os.Stderr, exitErr.message)
}
os.Exit(exitErr.code)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(exitGeneral)
}
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(exitGeneral)
}
}
type exitError struct {
code int
message string
}
func (e exitError) Error() string {
return e.message
}
func newRootCmd(opts *options) *cobra.Command {
cmd := &cobra.Command{
Use: "migration-cleanup",
Short: "Detect and fix Fleet migration renumbering",
SilenceUsage: true,
}
cmd.PersistentFlags().String("config", "", "Path to a Fleet configuration file")
cmd.Flags().StringVarP(&opts.checkout, "checkout", "c", ".", "Path to fleetdm/fleet git checkout")
cmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "Branch name")
cmd.Flags().StringVarP(&opts.output, "output", "o", "", "Write SQL to file instead of stdout")
cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Connect to MySQL, simulate the SQL, and verify the final state")
cmd.Flags().BoolVar(&opts.apply, "apply", false, "Execute SQL against the database in a transaction")
cmd.Flags().BoolVarP(&opts.verbose, "verbose", "v", false, "Verbose/debug output")
cmd.Flags().StringVar(&opts.dbHost, "db-host", "", "MySQL hostname")
cmd.Flags().IntVar(&opts.dbPort, "db-port", 3306, "MySQL port")
cmd.Flags().StringVar(&opts.dbName, "db-name", "", "MySQL database name")
cmd.Flags().StringVar(&opts.dbUser, "db-user", "", "MySQL username")
cmd.Flags().StringVarP(&opts.dbPassword, "db-password", "p", "", "MySQL password")
cmd.Flags().StringVar(&opts.tlsMode, "tls-mode", "", "TLS verification mode: skip-verify, verify-ca, verify-identity")
cmd.Flags().StringVar(&opts.tlsCA, "tls-ca", "", "CA certificate PEM path")
cmd.Flags().StringVar(&opts.tlsCert, "tls-cert", "", "Client certificate PEM path")
cmd.Flags().StringVar(&opts.tlsKey, "tls-key", "", "Client key PEM path")
return cmd
}
func hideUnneededFleetConfigFlags(cmd *cobra.Command) {
visibleMysqlFlags := map[string]struct{}{
"mysql_protocol": {},
"mysql_address": {},
"mysql_username": {},
"mysql_password": {},
"mysql_password_path": {},
"mysql_database": {},
"mysql_tls_cert": {},
"mysql_tls_key": {},
"mysql_tls_ca": {},
"mysql_tls_server_name": {},
"mysql_tls_config": {},
"mysql_region": {},
"mysql_sts_assume_role_arn": {},
"mysql_sts_external_id": {},
}
cmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag.Name == "config" {
return
}
if _, ok := visibleMysqlFlags[flag.Name]; ok {
return
}
flag.Hidden = true
})
}
func run(ctx context.Context, configManager configpkg.Manager, opts options) error {
if opts.branch == "" {
return exitError{code: exitGeneral, message: "ERROR: --branch is required"}
}
if opts.dryRun && opts.apply {
return exitError{code: exitGeneral, message: "ERROR: --dry-run and --apply are mutually exclusive"}
}
if err := validateTLSFlags(opts); err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
checkout, err := filepath.Abs(opts.checkout)
if err != nil {
return exitError{code: exitGeneral, message: fmt.Sprintf("ERROR: %v", err)}
}
if st, err := os.Stat(checkout); err != nil || !st.IsDir() {
return exitError{code: exitGeneral, message: fmt.Sprintf("ERROR: %q is not a directory", checkout)}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, "Checkout: %s\n", checkout)
}
if opts.verbose {
fmt.Fprintln(os.Stderr, "Fetching origin...")
}
if _, err := git(checkout, "fetch", "origin"); err != nil {
return exitError{code: exitGeneral, message: err.Error()}
}
branch, err := resolveBranch(checkout, opts.branch)
if err != nil {
return exitError{code: exitGeneral, message: err.Error()}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, "Resolved branch: %s\n", branch)
}
mergeBase, err := getMergeBase(checkout, branch)
if err != nil {
return exitError{code: exitGeneral, message: err.Error()}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, "Merge base: %s\n", mergeBase)
}
commits, err := findRenameCommits(checkout, branch, mergeBase)
if err != nil {
return exitError{code: exitGeneral, message: err.Error()}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, "Rename commits found: %d\n", len(commits))
}
var renames []migrationRename
for _, sha := range commits {
rs, err := extractRenames(checkout, sha)
if err != nil {
return exitError{code: exitGeneral, message: err.Error()}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, " %s: %d rename(s)\n", shortSHA(sha), len(rs))
}
renames = append(renames, rs...)
}
if len(renames) == 0 {
fmt.Println("No migration renumbering detected on this branch.")
return nil
}
renames = dedupeRenames(renames)
fmt.Fprintf(os.Stderr, "Found %d migration renumber(s):\n", len(renames))
for _, r := range renames {
fmt.Fprintf(os.Stderr, " [%s] %d -> %d (%s)\n", r.migrationType, r.oldVersionID, r.newVersionID, r.commitSHA)
}
var tableRows, dataRows []tableRow
var db *sqlx.DB
if opts.dryRun || opts.apply {
fleetConfig := configManager.LoadConfig()
mysqlConfig, err := writerConfig(fleetConfig.Mysql, opts)
if err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
db, err = openWriterDB(mysqlConfig)
if err != nil {
return exitError{code: exitGeneral, message: "ERROR: DB connection failed: " + err.Error()}
}
defer db.Close()
tableRows, err = queryTableRows(ctx, db, tableStatusName)
if err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
dataRows, err = queryTableRows(ctx, db, dataStatusName)
if err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
if opts.verbose {
fmt.Fprintf(os.Stderr, " %s: %d rows\n", tableStatusName, len(tableRows))
fmt.Fprintf(os.Stderr, " %s: %d rows\n", dataStatusName, len(dataRows))
}
}
statements := generateStatementGroups(renames)
sqlText := renderSQL(statements)
if opts.dryRun {
clean, messages := verifyDryRun(renames, tableRows, dataRows)
for _, msg := range messages {
fmt.Fprintln(os.Stderr, msg)
}
if clean {
fmt.Fprintln(os.Stderr, "Dry-run: SQL will apply cleanly.")
} else {
fmt.Fprintln(os.Stderr, "Dry-run: issues detected.")
}
if err := writeOutput(sqlText, opts.output); err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
if !clean {
return exitError{code: exitDryRun}
}
return nil
}
if opts.apply {
if err := applyStatements(ctx, db, statements); err != nil {
return exitError{code: exitApply, message: "ERROR: apply failed: " + err.Error()}
}
fmt.Fprintln(os.Stderr, "SQL applied successfully.")
return nil
}
if err := writeOutput(sqlText, opts.output); err != nil {
return exitError{code: exitGeneral, message: "ERROR: " + err.Error()}
}
return nil
}
func validateTLSFlags(opts options) error {
switch opts.tlsMode {
case "", "skip-verify", "verify-ca", "verify-identity":
default:
return errors.New("--tls-mode must be one of skip-verify, verify-ca, verify-identity")
}
return nil
}
func validateEffectiveTLSConfig(conf configpkg.MysqlConfig, tlsMode string) error {
if tlsMode == "verify-ca" || tlsMode == "verify-identity" {
if conf.TLSCA == "" {
return errors.New("--tls-ca or mysql_tls_ca is required for verify-ca / verify-identity")
}
}
if conf.TLSConfig != "skip-verify" && (conf.TLSCert == "") != (conf.TLSKey == "") {
return errors.New("TLS client certificate and key must be provided together")
}
return nil
}
func git(checkout string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", checkout}, args...)...)
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr)))
}
return "", fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
}
return string(out), nil
}
func resolveBranch(checkout, branch string) (string, error) {
candidates := []string{branch, "origin/" + branch}
var lastErr error
for _, candidate := range candidates {
_, err := git(checkout, "rev-parse", "--verify", candidate)
if err == nil {
return candidate, nil
}
lastErr = err
fmt.Fprintln(os.Stderr, err)
}
if lastErr != nil {
return "", fmt.Errorf("ERROR: cannot resolve branch %q", branch)
}
return "", fmt.Errorf("ERROR: cannot resolve branch %q", branch)
}
func getMergeBase(checkout, branch string) (string, error) {
out, err := git(checkout, "merge-base", "main", branch)
return strings.TrimSpace(out), err
}
func findRenameCommits(checkout, branch, mergeBase string) ([]string, error) {
args := []string{"log", "-M", "--diff-filter=R", "--format=%H", mergeBase + ".." + branch, "--"}
args = append(args, migrationDirs...)
out, err := git(checkout, args...)
if err != nil {
return nil, err
}
out = strings.TrimSpace(out)
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
func extractRenames(checkout, commitSHA string) ([]migrationRename, error) {
out, err := git(checkout, "diff-tree", "-M", "-r", "--diff-filter=R", "--name-status", "--no-commit-id", commitSHA)
if err != nil {
return nil, err
}
var renames []migrationRename
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 3 {
continue
}
oldPath, newPath := parts[1], parts[2]
oldMatch := tsRE.FindStringSubmatch(filepath.Base(oldPath))
newMatch := tsRE.FindStringSubmatch(filepath.Base(newPath))
if oldMatch == nil || newMatch == nil {
continue
}
oldVID, err := strconv.ParseInt(oldMatch[1], 10, 64)
if err != nil {
return nil, err
}
newVID, err := strconv.ParseInt(newMatch[1], 10, 64)
if err != nil {
return nil, err
}
if oldVID == newVID || strings.HasSuffix(oldPath, "_test.go") {
continue
}
var mtype string
switch {
case strings.Contains(oldPath, "/tables"):
mtype = "tables"
case strings.Contains(oldPath, "/data"):
mtype = "data"
default:
continue
}
renames = append(renames, migrationRename{
oldVersionID: oldVID,
newVersionID: newVID,
migrationType: mtype,
oldPath: oldPath,
newPath: newPath,
commitSHA: shortSHA(commitSHA),
})
}
return renames, nil
}
func shortSHA(sha string) string {
if len(sha) < 12 {
return sha
}
return sha[:12]
}
func dedupeRenames(renames []migrationRename) []migrationRename {
seen := map[string]struct{}{}
unique := make([]migrationRename, 0, len(renames))
for _, r := range renames {
key := fmt.Sprintf("%s:%d:%d", r.migrationType, r.oldVersionID, r.newVersionID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
unique = append(unique, r)
}
return unique
}
func generateStatementGroups(renames []migrationRename) map[string][]string {
groups := map[string][]string{}
for _, item := range []struct {
mtype string
table string
}{
{"tables", tableStatusName},
{"data", dataStatusName},
} {
var tableRenames []migrationRename
for _, r := range renames {
if r.migrationType == item.mtype {
tableRenames = append(tableRenames, r)
}
}
if len(tableRenames) == 0 {
continue
}
stmts := computeSQLForTable(item.table, tableRenames)
groups[item.table] = buildSQL(item.table, stmts, tableRenames)
}
return groups
}
func computeSQLForTable(tableName string, renames []migrationRename) sqlStatements {
stmts := sqlStatements{tableName: tableName}
for _, r := range renames {
stmts.versionIDRemappings = append(stmts.versionIDRemappings, [2]int64{r.oldVersionID, r.newVersionID})
}
return stmts
}
func buildSQL(tableName string, stmts sqlStatements, renames []migrationRename) []string {
lines := make([]string, 0)
for _, pair := range stmts.versionIDRemappings {
lines = append(lines, fmt.Sprintf("UPDATE `%s` SET version_id = %d WHERE version_id = %d;", tableName, pair[1], pair[0]))
}
if len(renames) == 0 {
return lines
}
lines = append(lines,
fmt.Sprintf("CREATE TEMPORARY TABLE `_fix_dups_%s` (id BIGINT UNSIGNED);", tableName),
fmt.Sprintf("INSERT INTO `_fix_dups_%s` (id) SELECT id FROM `%s` WHERE (version_id, id) NOT IN (SELECT version_id, MIN(id) FROM `%s` GROUP BY version_id);", tableName, tableName, tableName),
fmt.Sprintf("DELETE FROM `%s` WHERE id IN (SELECT id FROM `_fix_dups_%s`);", tableName, tableName),
fmt.Sprintf("DROP TEMPORARY TABLE `_fix_dups_%s`;", tableName),
)
minNewVID, maxNewVID := renames[0].newVersionID, renames[0].newVersionID
movesUp := false
for _, r := range renames {
if r.newVersionID < minNewVID {
minNewVID = r.newVersionID
}
if r.newVersionID > maxNewVID {
maxNewVID = r.newVersionID
}
if r.newVersionID > r.oldVersionID {
movesUp = true
}
}
varName := "increment_by_" + tableName
if !movesUp {
varName += "_shift"
}
maxMovedVar := "max_moved_down_" + tableName
lines = append(lines, fmt.Sprintf("SELECT (SELECT MAX(id) FROM `%s` WHERE id > (SELECT id FROM `%s` WHERE version_id = %d)) - (SELECT id FROM `%s` WHERE version_id = %d) + 1 INTO @%s;", tableName, tableName, maxNewVID, tableName, minNewVID, varName))
targetIDVar := "target_id_" + tableName
var whereClause string
if movesUp {
whereClause = fmt.Sprintf("WHERE version_id BETWEEN %d AND %d", minNewVID, maxNewVID)
lines = append(lines, fmt.Sprintf("SELECT %d INTO @%s;", maxNewVID, maxMovedVar))
} else {
// Extract the target row's id into a variable first so we don't reference
// the same table in a subquery inside an UPDATE (MySQL doesn't allow that).
lines = append(lines, fmt.Sprintf("SELECT id INTO @%s FROM `%s` WHERE version_id = %d;", targetIDVar, tableName, minNewVID))
whereClause = fmt.Sprintf("WHERE id < @%s AND version_id > %d", targetIDVar, maxNewVID)
lines = append(lines, fmt.Sprintf("SELECT MAX(version_id) INTO @%s FROM `%s` %s;", maxMovedVar, tableName, whereClause))
}
lines = append(lines,
fmt.Sprintf("UPDATE `%s` SET id = id + COALESCE(@%s, 0) WHERE version_id > @%s ORDER BY id DESC;", tableName, varName, maxMovedVar),
fmt.Sprintf("UPDATE `%s` SET id = id + COALESCE(@%s, 0) %s ORDER BY id DESC;", tableName, varName, whereClause),
)
return lines
}
func renderSQL(groups map[string][]string) string {
if len(groups) == 0 {
return "-- No changes needed.\n"
}
var b strings.Builder
b.WriteString("-- Migration renumber fix\n-- Generated by migration-cleanup\n\nSTART TRANSACTION;\n")
for _, tableName := range []string{tableStatusName, dataStatusName} {
lines, ok := groups[tableName]
if !ok {
continue
}
fmt.Fprintf(&b, "-- %s\n", tableName)
for _, line := range lines {
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
}
b.WriteString("COMMIT;\n")
return b.String()
}
func queryTableRows(ctx context.Context, db *sqlx.DB, tableName string) ([]tableRow, error) {
var rows []tableRow
if err := sqlx.SelectContext(ctx, db, &rows, fmt.Sprintf("SELECT id, version_id, is_applied FROM `%s` ORDER BY id", tableName)); err != nil {
return nil, fmt.Errorf("query %s: %w", tableName, err)
}
return rows, nil
}
func verifyDryRun(renames []migrationRename, tableRows, dataRows []tableRow) (bool, []string) {
var issues []string
var messages []string
for _, item := range []struct {
table string
mtype string
rows []tableRow
}{
{tableStatusName, "tables", tableRows},
{dataStatusName, "data", dataRows},
} {
var tableRenames []migrationRename
for _, r := range renames {
if r.migrationType == item.mtype {
tableRenames = append(tableRenames, r)
}
}
if len(tableRenames) == 0 {
continue
}
simulated, tableMessages, tableIssues := simulateTableSQL(item.table, item.rows, tableRenames)
messages = append(messages, tableMessages...)
issues = append(issues, tableIssues...)
if len(tableIssues) == 0 {
issues = append(issues, validateFinalTableState(item.table, simulated)...)
}
}
return len(issues) == 0, append(messages, issues...)
}
func simulateTableSQL(tableName string, rows []tableRow, renames []migrationRename) ([]tableRow, []string, []string) {
var messages []string
var issues []string
renameMap := map[int64]int64{}
existing := map[int64]struct{}{}
for _, row := range rows {
existing[row.VersionID] = struct{}{}
}
for _, r := range renames {
renameMap[r.oldVersionID] = r.newVersionID
if _, ok := existing[r.oldVersionID]; ok {
messages = append(messages, fmt.Sprintf(" %s: will remap %d -> %d", tableName, r.oldVersionID, r.newVersionID))
} else {
messages = append(messages, fmt.Sprintf(" %s: %d not present (UPDATE will be no-op)", tableName, r.oldVersionID))
}
}
simulated := make([]tableRow, 0, len(rows))
for _, row := range rows {
newVID := row.VersionID
if mapped, ok := renameMap[row.VersionID]; ok {
newVID = mapped
}
simulated = append(simulated, tableRow{ID: row.ID, VersionID: newVID, IsApplied: row.IsApplied})
}
byVID := map[int64][]tableRow{}
for _, row := range simulated {
byVID[row.VersionID] = append(byVID[row.VersionID], row)
}
simulated = simulated[:0]
for vid, vidRows := range byVID {
sort.Slice(vidRows, func(i, j int) bool { return vidRows[i].ID < vidRows[j].ID })
keep := vidRows[0]
simulated = append(simulated, keep)
if len(vidRows) > 1 {
deleted := make([]string, 0, len(vidRows)-1)
for _, row := range vidRows[1:] {
deleted = append(deleted, strconv.FormatInt(row.ID, 10))
}
messages = append(messages, fmt.Sprintf(" %s: duplicate version_id=%d; would keep id=%d, delete ids=[%s]", tableName, vid, keep.ID, strings.Join(deleted, " ")))
}
}
minNewVID, maxNewVID := renames[0].newVersionID, renames[0].newVersionID
movesUp := false
for _, r := range renames {
if r.newVersionID < minNewVID {
minNewVID = r.newVersionID
}
if r.newVersionID > maxNewVID {
maxNewVID = r.newVersionID
}
if r.newVersionID > r.oldVersionID {
movesUp = true
}
}
minRows := rowsForVID(simulated, minNewVID)
maxRows := rowsForVID(simulated, maxNewVID)
var targetRows []tableRow
if movesUp {
for _, row := range simulated {
if row.VersionID >= minNewVID && row.VersionID <= maxNewVID {
targetRows = append(targetRows, row)
}
}
} else {
if len(minRows) == 0 {
messages = append(messages, fmt.Sprintf(" %s: no row for min_new_vid=%d; id shift would affect 0 row(s)", tableName, minNewVID))
return simulated, messages, issues
}
for _, row := range simulated {
if row.ID < minRows[0].ID && row.VersionID > maxNewVID {
targetRows = append(targetRows, row)
}
}
}
if len(targetRows) == 0 {
messages = append(messages, fmt.Sprintf(" %s: id shift would affect 0 row(s)", tableName))
return simulated, messages, issues
}
if len(minRows) != 1 {
issues = append(issues, fmt.Sprintf(" %s: expected one row for min_new_vid=%d, found %d", tableName, minNewVID, len(minRows)))
return simulated, messages, issues
}
if len(maxRows) != 1 {
issues = append(issues, fmt.Sprintf(" %s: expected one row for max_new_vid=%d, found %d", tableName, maxNewVID, len(maxRows)))
return simulated, messages, issues
}
var idsAfterMax []int64
for _, row := range simulated {
if row.ID > maxRows[0].ID {
idsAfterMax = append(idsAfterMax, row.ID)
}
}
var offset int64
if len(idsAfterMax) == 0 {
messages = append(messages, fmt.Sprintf(" %s: generated offset would be NULL; COALESCE will shift by +0", tableName))
offset = 0
} else {
offset = maxInt64(idsAfterMax) - minRows[0].ID + 1
if offset <= 0 {
issues = append(issues, fmt.Sprintf(" %s: generated offset would be %d, expected a positive value", tableName, offset))
return simulated, messages, issues
}
}
maxMovedDownVID := targetRows[0].VersionID
for _, row := range targetRows {
if row.VersionID > maxMovedDownVID {
maxMovedDownVID = row.VersionID
}
}
spaceIDs := map[int64]struct{}{}
for _, row := range simulated {
if row.VersionID > maxMovedDownVID {
spaceIDs[row.ID] = struct{}{}
}
}
withSpace := shiftRows(simulated, spaceIDs, offset)
messages = append(messages, fmt.Sprintf(" %s: would make space by shifting %d row(s) after version_id=%d by +%d", tableName, len(spaceIDs), maxMovedDownVID, offset))
targetIDs := map[int64]struct{}{}
for _, row := range targetRows {
targetIDs[row.ID] = struct{}{}
}
shifted := shiftRows(withSpace, targetIDs, offset)
messages = append(messages, fmt.Sprintf(" %s: would shift %d row(s) by +%d", tableName, len(targetRows), offset))
return shifted, messages, issues
}
func rowsForVID(rows []tableRow, vid int64) []tableRow {
var out []tableRow
for _, row := range rows {
if row.VersionID == vid {
out = append(out, row)
}
}
return out
}
func maxInt64(values []int64) int64 {
maxVal := values[0]
for _, val := range values[1:] {
if val > maxVal {
maxVal = val
}
}
return maxVal
}
func shiftRows(rows []tableRow, ids map[int64]struct{}, offset int64) []tableRow {
shifted := make([]tableRow, 0, len(rows))
for _, row := range rows {
newID := row.ID
if _, ok := ids[row.ID]; ok {
newID += offset
}
shifted = append(shifted, tableRow{ID: newID, VersionID: row.VersionID, IsApplied: row.IsApplied})
}
return shifted
}
func validateFinalTableState(tableName string, rows []tableRow) []string {
var issues []string
ids := map[int64][]int64{}
for _, row := range rows {
ids[row.ID] = append(ids[row.ID], row.VersionID)
}
for id, vids := range ids {
if len(vids) > 1 {
issues = append(issues, fmt.Sprintf(" %s: duplicate id=%d after simulated fix (version_ids=%v)", tableName, id, vids))
}
}
var applied []tableRow
for _, row := range rows {
if row.IsApplied && row.VersionID > 0 {
applied = append(applied, row)
}
}
sort.Slice(applied, func(i, j int) bool { return applied[i].ID < applied[j].ID })
appliedVIDs := map[int64][]int64{}
for _, row := range applied {
appliedVIDs[row.VersionID] = append(appliedVIDs[row.VersionID], row.ID)
}
for vid, ids := range appliedVIDs {
if len(ids) > 1 {
issues = append(issues, fmt.Sprintf(" %s: duplicate applied version_id=%d after simulated fix (ids=%v)", tableName, vid, ids))
}
}
for i := 0; i < len(applied)-1; i++ {
if applied[i].VersionID > applied[i+1].VersionID {
issues = append(issues, fmt.Sprintf(" %s: ordering violation after simulated fix -- id=%d (vid=%d) > id=%d (vid=%d)", tableName, applied[i].ID, applied[i].VersionID, applied[i+1].ID, applied[i+1].VersionID))
}
}
return issues
}
func writerConfig(conf configpkg.MysqlConfig, opts options) (*configpkg.MysqlConfig, error) {
if opts.dbHost != "" {
conf.Address = fmt.Sprintf("%s:%d", opts.dbHost, opts.dbPort)
}
if opts.dbName != "" {
conf.Database = opts.dbName
}
if opts.dbUser != "" {
conf.Username = opts.dbUser
}
if opts.dbPassword != "" {
conf.Password = opts.dbPassword
} else if env := os.Getenv("FLEET_DB_PASSWORD"); env != "" {
conf.Password = env
}
if conf.Password == "" && conf.PasswordPath == "" && conf.Region == "" {
pass, err := promptPassword()
if err != nil {
return nil, err
}
conf.Password = pass
}
if opts.tlsMode == "skip-verify" {
conf.TLSConfig = "skip-verify"
}
if opts.tlsCA != "" {
conf.TLSCA = opts.tlsCA
}
if opts.tlsCert != "" {
conf.TLSCert = opts.tlsCert
}
if opts.tlsKey != "" {
conf.TLSKey = opts.tlsKey
}
if err := validateEffectiveTLSConfig(conf, opts.tlsMode); err != nil {
return nil, err
}
return &conf, nil
}
func promptPassword() (string, error) {
fmt.Fprint(os.Stderr, "MySQL password: ")
if term.IsTerminal(int(os.Stdin.Fd())) {
bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return string(bytes), err
}
reader := bufio.NewReader(os.Stdin)
pass, err := reader.ReadString('\n')
return strings.TrimSpace(pass), err
}
func openWriterDB(conf *configpkg.MysqlConfig) (*sqlx.DB, error) {
if conf.PasswordPath != "" && conf.Password != "" {
return nil, errors.New("a MySQL password and password file were provided; specify only one")
}
if conf.PasswordPath != "" {
contents, err := os.ReadFile(conf.PasswordPath)
if err != nil {
return nil, err
}
conf.Password = strings.TrimSpace(string(contents))
}
if conf.TLSCA != "" && conf.TLSConfig != "skip-verify" {
tlsConfigName := fmt.Sprintf("migration-cleanup-%d", time.Now().UnixNano())
tlsOpts := configpkg.TLS{
TLSCert: conf.TLSCert,
TLSKey: conf.TLSKey,
TLSCA: conf.TLSCA,
TLSServerName: conf.TLSServerName,
}
tlsConfig, err := tlsOpts.ToTLSConfig()
if err != nil {
return nil, err
}
if err := gomysql.RegisterTLSConfig(tlsConfigName, tlsConfig); err != nil {
return nil, err
}
conf.TLSConfig = tlsConfigName
}
commonConf := &commonmysql.MysqlConfig{
Protocol: conf.Protocol,
Address: conf.Address,
Username: conf.Username,
Password: conf.Password,
PasswordPath: conf.PasswordPath,
Database: conf.Database,
TLSCert: conf.TLSCert,
TLSKey: conf.TLSKey,
TLSCA: conf.TLSCA,
TLSServerName: conf.TLSServerName,
TLSConfig: conf.TLSConfig,
MaxOpenConns: conf.MaxOpenConns,
MaxIdleConns: conf.MaxIdleConns,
ConnMaxLifetime: conf.ConnMaxLifetime,
SQLMode: conf.SQLMode,
Region: conf.Region,
}
return commonmysql.NewDB(commonConf, &commonmysql.DBOptions{
MaxAttempts: 1,
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}, "mysql")
}
func applyStatements(ctx context.Context, db *sqlx.DB, groups map[string][]string) error {
var statements []string
for _, tableName := range []string{tableStatusName, dataStatusName} {
statements = append(statements, groups[tableName]...)
}
return commonmysql.WithTxx(ctx, db, func(tx sqlx.ExtContext) error {
for _, stmt := range statements {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("execute %q: %w", stmt, err)
}
}
return nil
}, slog.New(slog.NewTextHandler(os.Stderr, nil)))
}
func writeOutput(sqlText, outputPath string) error {
if outputPath == "" {
fmt.Print(sqlText)
return nil
}
if err := os.WriteFile(outputPath, []byte(sqlText), 0o644); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "SQL written to %s\n", outputPath)
return nil
}