syncova-backup/apps/api/cmd/syncova-migrate/main.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

241 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
}