Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.6 KiB
Go
241 lines
8.6 KiB
Go
// 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 <n> 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 <version>\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 <version>", 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"
|
||
}
|