// Command syncova-migrate verwaltet das Schema der Control-Plane-Datenbank. // // Migrationen laufen bewusst als eigenes Kommando und nicht beim Start des // API-Dienstes: ein Produktionsstart darf das Schema niemals stillschweigend // verändern (SYNCOVA_DATABASE.md §18). // // Aufruf: // // syncova-migrate up – wendet alle ausstehenden Migrationen an // syncova-migrate status – zeigt den aktuellen Migrationsstand // syncova-migrate down – nimmt genau eine Migration zurück package main import ( "fmt" "log/slog" "os" "strconv" "github.com/syncova/syncova/migrations" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/database" "github.com/syncova/syncova/packages/platform/logging" ) // serviceName benennt das Kommando in den Logs. const serviceName = "syncova-migrate" // confirmDownVariable ist die Umgebungsvariable, die einen Rücklauf in der // Produktion ausdrücklich freigibt. const confirmDownVariable = "SYNCOVA_MIGRATE_CONFIRM_DOWN" // buildVersion wird beim Bauen über -ldflags gesetzt. // // Der Vorgabewert gilt nur für einen Bau von Hand; das Auslieferungspaket // brennt die tatsächliche Fassung ein. var buildVersion = "0.1.0-dev" func main() { if runError := run(); runError != nil { fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError) os.Exit(1) } } // run wertet das Unterkommando aus und führt es aus. func run() error { if len(os.Args) < 2 { return fmt.Errorf("kein Kommando angegeben.\n\nVerwendung:\n"+ " %s up Migrationen anwenden\n"+ " %s status Migrationsstand anzeigen\n"+ " %s down eine Migration zurücknehmen\n"+ " %s force Stand nach abgebrochener Migration setzen", serviceName, serviceName, serviceName, serviceName) } // Die Versionsabfrage steht vor dem Laden der Konfiguration: Wer wissen // will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich // keine Datenbank — etwa auf einem frisch ausgepackten Paket. if len(os.Args) > 1 && isVersionArgument(os.Args[1]) { fmt.Printf("%s %s\n", serviceName, buildVersion) return nil } serviceConfig, configError := config.Load(serviceName) if configError != nil { return configError } commandLogger := logging.New(os.Stdout, logging.Options{ ServiceName: serviceConfig.ServiceName, Level: serviceConfig.Logging.Level, Format: serviceConfig.Logging.Format, }) // Die DSN enthält das Passwort und wird deshalb niemals geloggt oder ausgegeben. connectionString := serviceConfig.Database.ConnectionString() switch requestedCommand := os.Args[1]; requestedCommand { case "up": return runUp(connectionString, commandLogger, serviceConfig) case "status": return runStatus(connectionString, serviceConfig) case "down": return runDown(connectionString, commandLogger, serviceConfig) case "force": return runForce(connectionString, commandLogger, serviceConfig) default: return fmt.Errorf("unbekanntes Kommando %q (erlaubt: up, status, down, force)", requestedCommand) } } // runUp wendet alle ausstehenden Migrationen an. func runUp(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error { commandLogger.Info("migrationen werden angewandt", slog.String("target", serviceConfig.Database.RedactedConnectionString())) appliedMigrations, migrationError := database.MigrateUp(migrations.FS, connectionString) if migrationError != nil { return migrationError } if !appliedMigrations { commandLogger.Info("das schema war bereits aktuell") return nil } migrationState, stateError := database.CurrentState(migrations.FS, connectionString) if stateError != nil { return stateError } commandLogger.Info("migrationen angewandt", slog.Uint64("version", uint64(migrationState.Version))) return nil } // runStatus gibt den aktuellen Migrationsstand aus. func runStatus(connectionString string, serviceConfig config.Config) error { migrationState, stateError := database.CurrentState(migrations.FS, connectionString) if stateError != nil { return stateError } fmt.Printf("Datenbank: %s\n", serviceConfig.Database.RedactedConnectionString()) if !migrationState.HasAnyMigration { fmt.Println("Schemastand: noch nicht migriert") fmt.Println("Nächster Schritt: syncova-migrate up") return nil } fmt.Printf("Schemastand: Version %d\n", migrationState.Version) // Ein abgebrochener Migrationslauf muss deutlich sichtbar sein (PROMPT.md §140). if migrationState.IsDirty { fmt.Println("Zustand: ABGEBROCHENE MIGRATION – das Schema ist in einem unklaren Zustand.") fmt.Println("Nächster Schritt: Prüfen Sie, welche Anweisungen der abgebrochenen Migration") fmt.Println("bereits gewirkt haben, und stellen Sie das Schema von Hand auf einen bekannten") fmt.Printf("Stand. Danach setzen Sie die Markierung mit: %s force \n", serviceName) return nil } fmt.Println("Zustand: konsistent") return nil } // confirmForceVariable bestätigt das Setzen des Migrationsstands. const confirmForceVariable = "SYNCOVA_MIGRATE_CONFIRM_FORCE" // runForce setzt den Migrationsstand nach einer abgebrochenen Migration. // // Das Kommando führt kein SQL aus, sondern behauptet einen Stand. Es ist damit // gefährlicher als jedes andere: Wer es falsch anwendet, lässt die Anwendung // gegen ein Schema arbeiten, das sie für ein anderes hält. Deshalb verlangt es // dieselbe ausdrückliche Bestätigung wie ein Rücklauf — und gibt vorher aus, // was es vorfindet. func runForce(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error { if len(os.Args) < 3 { return fmt.Errorf("es wurde keine Zielversion angegeben.\n\nVerwendung: %s force ", serviceName) } targetVersion, parseError := strconv.Atoi(os.Args[2]) if parseError != nil || targetVersion < 0 { return fmt.Errorf("%q ist keine gültige Migrationsversion", os.Args[2]) } migrationState, stateError := database.CurrentState(migrations.FS, connectionString) if stateError != nil { return stateError } // Ein force auf einer sauberen Datenbank ist fast immer ein Irrtum: Es gibt // dort nichts zu retten, und die Folge wäre ein übersprungenes Schema. if migrationState.HasAnyMigration && !migrationState.IsDirty { return fmt.Errorf("die Datenbank steht sauber auf Version %d; force ist nur nach einer abgebrochenen Migration gedacht", migrationState.Version) } fmt.Printf("Vorgefundener Stand: Version %d (abgebrochen: %t)\n", migrationState.Version, migrationState.IsDirty) fmt.Printf("Neuer Stand: Version %d\n\n", targetVersion) fmt.Println("ACHTUNG: Dieses Kommando ändert das Schema NICHT. Es behauptet nur einen Stand.") fmt.Println("Prüfen Sie zuerst, ob das Schema dem Zielstand tatsächlich entspricht.") if os.Getenv(confirmForceVariable) != "yes" { return fmt.Errorf("\nabgebrochen: setzen Sie %s=yes, um fortzufahren", confirmForceVariable) } if forceError := database.ForceVersion(migrations.FS, connectionString, targetVersion); forceError != nil { return forceError } commandLogger.Warn("migrationsstand wurde von hand gesetzt", slog.Int("von_version", int(migrationState.Version)), slog.Int("auf_version", targetVersion), slog.String("target", serviceConfig.Database.RedactedConnectionString())) return nil } // runDown nimmt genau eine Migration zurück. func runDown(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error { // Ein Rücklauf ist potenziell datenzerstörend und in der Produktion nur // nach ausdrücklicher Bestätigung zulässig (PROMPT.md §141). if serviceConfig.Environment.IsProduction() && os.Getenv(confirmDownVariable) != "yes" { return fmt.Errorf("das Zurücknehmen einer Migration kann Daten löschen. "+ "In der Produktion ist dafür %s=yes erforderlich", confirmDownVariable) } commandLogger.Warn("eine migration wird zurückgenommen", slog.String("target", serviceConfig.Database.RedactedConnectionString())) if migrationError := database.MigrateDownOneStep(migrations.FS, connectionString); migrationError != nil { return migrationError } migrationState, stateError := database.CurrentState(migrations.FS, connectionString) if stateError != nil { return stateError } commandLogger.Info("migration zurückgenommen", slog.Uint64("version", uint64(migrationState.Version))) return nil } // isVersionArgument erkennt eine Versionsabfrage. // // Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm // erwartet — und weil eine Fehlermeldung auf "--version" der denkbar // schlechteste erste Eindruck ist. func isVersionArgument(argument string) bool { return argument == "version" || argument == "--version" || argument == "-version" }