diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 2717a10..f32b145 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -167,11 +167,30 @@ func main() { // Always apply SQL migrations on startup when database is available. // This keeps schema in sync for long-running installations without manual steps. + // If current DB user does not have enough privileges, continue startup in normal mode. if mariaDB != nil { sqlMigrationsPath := filepath.Join("migrations") - if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { - slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err) - os.Exit(1) + needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath) + if err != nil { + if models.IsMigrationPermissionError(err) { + slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) + } else { + slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err) + os.Exit(1) + } + } else if needsMigrations { + if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { + if models.IsMigrationPermissionError(err) { + slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) + } else { + slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err) + os.Exit(1) + } + } else { + slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath) + } + } else { + slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath) } } diff --git a/internal/models/sql_migrations.go b/internal/models/sql_migrations.go index 072025d..711fe04 100644 --- a/internal/models/sql_migrations.go +++ b/internal/models/sql_migrations.go @@ -2,6 +2,7 @@ package models import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "strings" "time" + mysqlDriver "github.com/go-sql-driver/mysql" "gorm.io/gorm" ) @@ -22,6 +24,30 @@ func (SQLSchemaMigration) TableName() string { return "qt_schema_migrations" } +// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir +// is not yet recorded in qt_schema_migrations. +func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) { + files, err := listSQLMigrationFiles(migrationsDir) + if err != nil { + return false, err + } + if len(files) == 0 { + return false, nil + } + + // If tracking table does not exist yet, migrations are required. + if !db.Migrator().HasTable(&SQLSchemaMigration{}) { + return true, nil + } + + var count int64 + if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil { + return false, fmt.Errorf("check applied migrations: %w", err) + } + + return count < int64(len(files)), nil +} + // RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations. // Local SQLite-only scripts are skipped automatically. func RunSQLMigrations(db *gorm.DB, migrationsDir string) error { @@ -29,27 +55,11 @@ func RunSQLMigrations(db *gorm.DB, migrationsDir string) error { return fmt.Errorf("migrate qt_schema_migrations table: %w", err) } - entries, err := os.ReadDir(migrationsDir) + files, err := listSQLMigrationFiles(migrationsDir) if err != nil { - return fmt.Errorf("read migrations dir %s: %w", migrationsDir, err) + return err } - files := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(strings.ToLower(name), ".sql") { - continue - } - if isSQLiteOnlyMigration(name) { - continue - } - files = append(files, name) - } - sort.Strings(files) - for _, filename := range files { var count int64 if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil { @@ -84,6 +94,37 @@ func RunSQLMigrations(db *gorm.DB, migrationsDir string) error { return nil } +// IsMigrationPermissionError returns true if err indicates insufficient privileges +// to create/alter/read migration metadata or target schema objects. +func IsMigrationPermissionError(err error) bool { + if err == nil { + return false + } + + var mysqlErr *mysqlDriver.MySQLError + if errors.As(err, &mysqlErr) { + switch mysqlErr.Number { + case 1044, 1045, 1142, 1143, 1227: + return true + } + } + + lower := strings.ToLower(err.Error()) + patterns := []string{ + "command denied to user", + "access denied for user", + "permission denied", + "insufficient privilege", + "sqlstate 42000", + } + for _, pattern := range patterns { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + func ensureSQLMigrationsTable(db *gorm.DB) error { stmt := ` CREATE TABLE IF NOT EXISTS qt_schema_migrations ( @@ -157,3 +198,30 @@ func splitSQLStatements(script string) []string { } return stmts } + +func listSQLMigrationFiles(migrationsDir string) ([]string, error) { + entries, err := os.ReadDir(migrationsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".sql") { + continue + } + if isSQLiteOnlyMigration(name) { + continue + } + files = append(files, name) + } + sort.Strings(files) + return files, nil +}