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>
480 lines
16 KiB
Go
480 lines
16 KiB
Go
// Command syncova-dr stellt eine Anlage nach einem Totalverlust wieder her
|
||
// (SYNCOVA_IMPLEMENTATION_PLAN.md §20).
|
||
//
|
||
// Das Werkzeug bedient man an dem Tag, an dem nichts mehr da ist. Daraus folgen
|
||
// drei Eigenschaften, die es von den uebrigen Kommandos unterscheiden:
|
||
//
|
||
// „inspect" braucht **keine Datenbank**. Nach einem Totalverlust will man zuerst
|
||
// sehen, was ueberhaupt noch da ist, bevor man einen Server aufsetzt.
|
||
//
|
||
// Jeder Schritt sagt, was er getan hat **und was noch zu tun bleibt**. Wer eine
|
||
// Anlage aus dem Nichts wiederherstellt, hat keine Betriebsanleitung neben sich
|
||
// liegen; die Ausgabe muss die Anleitung sein.
|
||
//
|
||
// Nichts laeuft von selbst wieder an. Auftraege kommen angehalten zurueck,
|
||
// Repositories als nicht erreichbar, Benachrichtigungswege abgeschaltet. Eine
|
||
// Anlage, die nach dem Wiederaufbau um zwei Uhr nachts von selbst auf ein halb
|
||
// hergestelltes System schreibt, waere schlimmer als eine, die stillsteht.
|
||
//
|
||
// Aufruf:
|
||
//
|
||
// syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
|
||
// syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
|
||
// syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/syncova/syncova/packages/disasterrecovery"
|
||
"github.com/syncova/syncova/packages/platform/config"
|
||
"github.com/syncova/syncova/packages/platform/database"
|
||
"github.com/syncova/syncova/packages/platform/logging"
|
||
"github.com/syncova/syncova/packages/repository"
|
||
)
|
||
|
||
// serviceName benennt das Kommando in den Logs.
|
||
const serviceName = "syncova-dr"
|
||
|
||
// commandTimeout begrenzt die Laufzeit einer Datenbankoperation.
|
||
//
|
||
// Grosszuegig bemessen: Der Katalogaufbau liest jedes Manifest des Repositorys,
|
||
// und bei einer grossen Anlage sind das viele tausend Dateien.
|
||
const commandTimeout = 30 * time.Minute
|
||
|
||
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||
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.
|
||
func run() error {
|
||
// Die Versionsabfrage steht vor allem anderen: Wer wissen will, welche
|
||
// Fassung auf einem Server liegt, hat in dem Moment womöglich keine
|
||
// Konfiguration — etwa auf einem frisch ausgepackten Paket.
|
||
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||
|
||
return nil
|
||
}
|
||
|
||
if len(os.Args) < 2 {
|
||
printUsage()
|
||
|
||
return fmt.Errorf("es wurde kein kommando angegeben")
|
||
}
|
||
|
||
commandName := os.Args[1]
|
||
commandArguments := os.Args[2:]
|
||
|
||
switch commandName {
|
||
case "export":
|
||
return runExport(commandArguments)
|
||
case "inspect":
|
||
return runInspect(commandArguments)
|
||
case "restore":
|
||
return runRestore(commandArguments)
|
||
case "help", "-h", "--help":
|
||
printUsage()
|
||
|
||
return nil
|
||
default:
|
||
printUsage()
|
||
|
||
return fmt.Errorf("unbekanntes kommando %q", commandName)
|
||
}
|
||
}
|
||
|
||
// printUsage schreibt die Kurzhilfe.
|
||
func printUsage() {
|
||
fmt.Fprintf(os.Stderr, `%s: Verwendung:
|
||
syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
|
||
syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
|
||
syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
|
||
|
||
Der Weg nach einem Totalverlust:
|
||
1. syncova-migrate up Schema anlegen
|
||
2. syncova-dr inspect --repo <pfad> Ansehen, was da ist
|
||
3. syncova-dr restore --repo <pfad> --catalog Konfiguration und Wiederherstellungspunkte
|
||
4. syncova-admin create-admin --username <n> Ersten Zugang anlegen
|
||
`, serviceName)
|
||
}
|
||
|
||
// runExport sichert die Konfiguration ins Repository.
|
||
func runExport(commandArguments []string) error {
|
||
exportFlags := flag.NewFlagSet("export", flag.ContinueOnError)
|
||
repositoryPath := exportFlags.String("repo", "", "Pfad des Repositorys")
|
||
|
||
if parseError := exportFlags.Parse(commandArguments); parseError != nil {
|
||
return parseError
|
||
}
|
||
|
||
if *repositoryPath == "" {
|
||
return fmt.Errorf("--repo ist erforderlich")
|
||
}
|
||
|
||
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
|
||
defer cancelCommand()
|
||
|
||
environment, environmentError := buildEnvironment(commandContext)
|
||
if environmentError != nil {
|
||
return environmentError
|
||
}
|
||
|
||
defer environment.close()
|
||
|
||
openRepository, openError := repository.Open(commandContext, *repositoryPath,
|
||
repository.OpenOptions{}, environment.logger)
|
||
if openError != nil {
|
||
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
|
||
}
|
||
|
||
defer func() { _ = openRepository.Close() }()
|
||
|
||
repositoryDescriptor := openRepository.Descriptor()
|
||
|
||
snapshotExporter := disasterrecovery.NewExporter(environment.databasePool.Connections())
|
||
|
||
configurationSnapshot, buildError := snapshotExporter.BuildSnapshot(commandContext,
|
||
repositoryDescriptor.RepositoryID, serviceName, buildVersion)
|
||
if buildError != nil {
|
||
return buildError
|
||
}
|
||
|
||
snapshotPath, writeError := disasterrecovery.WriteSnapshot(openRepository.RootPath(),
|
||
configurationSnapshot)
|
||
if writeError != nil {
|
||
return writeError
|
||
}
|
||
|
||
fmt.Printf("Konfiguration gesichert: %s\n", snapshotPath)
|
||
fmt.Printf(" %s\n", configurationSnapshot.Summary())
|
||
fmt.Printf(" Schemastand %d, erzeugt %s\n",
|
||
configurationSnapshot.SchemaVersion,
|
||
configurationSnapshot.CreatedAt.Format(time.RFC3339))
|
||
fmt.Println()
|
||
fmt.Println("Nicht enthalten (und nach einer Wiederherstellung von Hand zu erledigen):")
|
||
|
||
for _, omission := range configurationSnapshot.OmittedForSecurity {
|
||
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// runInspect zeigt den Sicherungssatz eines Repositorys.
|
||
//
|
||
// **Ohne Datenbank.** Das ist der Zweck: Wer nach einem Ausfall vor einem
|
||
// Repository steht, muss sehen koennen, was darin ist, bevor er entscheidet, wie
|
||
// er weitermacht.
|
||
func runInspect(commandArguments []string) error {
|
||
inspectFlags := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||
repositoryPath := inspectFlags.String("repo", "", "Pfad des Repositorys")
|
||
|
||
if parseError := inspectFlags.Parse(commandArguments); parseError != nil {
|
||
return parseError
|
||
}
|
||
|
||
if *repositoryPath == "" {
|
||
return fmt.Errorf("--repo ist erforderlich")
|
||
}
|
||
|
||
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
|
||
if readError != nil {
|
||
return readError
|
||
}
|
||
|
||
fmt.Println("Sicherungssatz der Konfiguration")
|
||
fmt.Printf(" Repository: %s\n", configurationSnapshot.RepositoryID)
|
||
fmt.Printf(" Erzeugt: %s von %s\n",
|
||
configurationSnapshot.CreatedAt.Format(time.RFC3339), configurationSnapshot.CreatedBy)
|
||
fmt.Printf(" Programm: %s\n", configurationSnapshot.ProductVersion)
|
||
fmt.Printf(" Schemastand: %d\n", configurationSnapshot.SchemaVersion)
|
||
fmt.Printf(" Inhalt: %s\n", configurationSnapshot.Summary())
|
||
|
||
if len(configurationSnapshot.Repositories) > 0 {
|
||
fmt.Println("\nRepositories:")
|
||
|
||
for _, repositoryRecord := range configurationSnapshot.Repositories {
|
||
fmt.Printf(" %-24s %s\n", repositoryRecord.Name, repositoryRecord.Location)
|
||
}
|
||
}
|
||
|
||
if len(configurationSnapshot.Jobs) > 0 {
|
||
fmt.Println("\nAufträge:")
|
||
|
||
for _, jobRecord := range configurationSnapshot.Jobs {
|
||
fmt.Printf(" %-24s %d Quelle(n), Zeitplan %s\n",
|
||
jobRecord.Name, len(jobRecord.Sources), jobRecord.ScheduleType)
|
||
}
|
||
}
|
||
|
||
fmt.Println("\nNicht enthalten (nach der Wiederherstellung von Hand zu erledigen):")
|
||
|
||
for _, omission := range configurationSnapshot.OmittedForSecurity {
|
||
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// runRestore spielt Konfiguration und Katalog ein.
|
||
func runRestore(commandArguments []string) error {
|
||
restoreFlags := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||
repositoryPath := restoreFlags.String("repo", "", "Pfad des Repositorys")
|
||
includeCatalog := restoreFlags.Bool("catalog", false,
|
||
"Wiederherstellungspunkte aus den Manifesten übernehmen")
|
||
|
||
if parseError := restoreFlags.Parse(commandArguments); parseError != nil {
|
||
return parseError
|
||
}
|
||
|
||
if *repositoryPath == "" {
|
||
return fmt.Errorf("--repo ist erforderlich")
|
||
}
|
||
|
||
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
|
||
defer cancelCommand()
|
||
|
||
environment, environmentError := buildEnvironment(commandContext)
|
||
if environmentError != nil {
|
||
return environmentError
|
||
}
|
||
|
||
defer environment.close()
|
||
|
||
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
|
||
if readError != nil {
|
||
return readError
|
||
}
|
||
|
||
snapshotImporter := disasterrecovery.NewImporter(environment.databasePool.Connections())
|
||
|
||
importResult, importError := snapshotImporter.ImportSnapshot(commandContext, configurationSnapshot)
|
||
if importError != nil {
|
||
return importError
|
||
}
|
||
|
||
printImportResult(importResult)
|
||
|
||
if *includeCatalog {
|
||
if catalogError := restoreCatalog(commandContext, environment, *repositoryPath,
|
||
configurationSnapshot); catalogError != nil {
|
||
return catalogError
|
||
}
|
||
} else {
|
||
fmt.Println("\nDie Wiederherstellungspunkte wurden NICHT übernommen (--catalog fehlt).")
|
||
fmt.Println("Ohne sie kennt die Anlage die vorhandenen Sicherungen nicht.")
|
||
}
|
||
|
||
printNextSteps(importResult, *includeCatalog)
|
||
|
||
return nil
|
||
}
|
||
|
||
// restoreCatalog uebernimmt die Wiederherstellungspunkte aus dem Repository.
|
||
func restoreCatalog(commandContext context.Context, environment *commandEnvironment,
|
||
repositoryPath string, configurationSnapshot *disasterrecovery.Snapshot) error {
|
||
openRepository, openError := repository.Open(commandContext, repositoryPath,
|
||
repository.OpenOptions{}, environment.logger)
|
||
if openError != nil {
|
||
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
|
||
}
|
||
|
||
defer func() { _ = openRepository.Close() }()
|
||
|
||
repositoryDescriptor := openRepository.Descriptor()
|
||
|
||
// Das Repository muss dasselbe sein, das der Sicherungssatz beschreibt.
|
||
// Andernfalls landeten die Wiederherstellungspunkte unter einer fremden
|
||
// Repository-Kennung — und ein spaeterer Restore suchte sie am falschen Ort.
|
||
if repositoryDescriptor.RepositoryID != configurationSnapshot.RepositoryID {
|
||
return fmt.Errorf("das repository traegt die kennung %s, der sicherungssatz "+
|
||
"beschreibt %s", repositoryDescriptor.RepositoryID, configurationSnapshot.RepositoryID)
|
||
}
|
||
|
||
databaseRepositoryIdentifier, resolveError := resolveRepositoryIdentifier(
|
||
configurationSnapshot, repositoryDescriptor.RepositoryID)
|
||
if resolveError != nil {
|
||
return resolveError
|
||
}
|
||
|
||
fmt.Println("\nWiederherstellungspunkte werden aus den Manifesten aufgebaut …")
|
||
|
||
catalogImporter := disasterrecovery.NewCatalogImporter(environment.databasePool.Connections())
|
||
|
||
catalogResult, catalogError := catalogImporter.ImportCatalog(commandContext,
|
||
openRepository, databaseRepositoryIdentifier)
|
||
if catalogError != nil {
|
||
return catalogError
|
||
}
|
||
|
||
fmt.Printf(" %d Wiederherstellungspunkte übernommen, %d bereits bekannt, %d Ketten angelegt\n",
|
||
catalogResult.BackupsImported, catalogResult.BackupsAlreadyKnown,
|
||
catalogResult.ChainsCreated)
|
||
|
||
if len(catalogResult.UnresolvedParents) > 0 {
|
||
fmt.Printf(" %d Zusatzsicherung(en) ohne auffindbares Elternbackup. Sie bleiben "+
|
||
"nutzbar: Syncova-Manifeste sind vollständig.\n", len(catalogResult.UnresolvedParents))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// resolveRepositoryIdentifier findet die Datenbankkennung des Repositorys.
|
||
func resolveRepositoryIdentifier(configurationSnapshot *disasterrecovery.Snapshot,
|
||
repositoryUUID string) (uuid.UUID, error) {
|
||
for _, repositoryRecord := range configurationSnapshot.Repositories {
|
||
if repositoryRecord.RepositoryUUID != repositoryUUID {
|
||
continue
|
||
}
|
||
|
||
parsedIdentifier, parseError := uuid.Parse(repositoryRecord.ID)
|
||
if parseError != nil {
|
||
return uuid.Nil, fmt.Errorf("die kennung des repositorys %q ist unlesbar: %w",
|
||
repositoryRecord.Name, parseError)
|
||
}
|
||
|
||
return parsedIdentifier, nil
|
||
}
|
||
|
||
return uuid.Nil, fmt.Errorf("der sicherungssatz kennt kein repository mit der kennung %s. "+
|
||
"ohne diese zuordnung liessen sich die wiederherstellungspunkte keinem eingerichteten "+
|
||
"repository zuordnen", repositoryUUID)
|
||
}
|
||
|
||
// printImportResult schreibt das Ergebnis der Konfigurationswiederherstellung.
|
||
func printImportResult(importResult *disasterrecovery.ImportResult) {
|
||
fmt.Println("Konfiguration wiederhergestellt:")
|
||
fmt.Printf(" %d Repositories (als 'nicht erreichbar' — prüfen Sie die Pfade)\n",
|
||
importResult.RepositoriesRestored)
|
||
fmt.Printf(" %d Aufbewahrungsregeln\n", importResult.RetentionPoliciesRestored)
|
||
fmt.Printf(" %d Aufträge mit %d Quellen (ANGEHALTEN)\n",
|
||
importResult.JobsRestored, importResult.JobSourcesRestored)
|
||
fmt.Printf(" %d Wartungsfenster\n", importResult.MaintenanceWindowsRestored)
|
||
fmt.Printf(" %d Benachrichtigungswege (ABGESCHALTET — ohne Zugangsdaten)\n",
|
||
importResult.NotificationChannelsRestored)
|
||
fmt.Printf(" %d Konten (DEAKTIVIERT — ohne Passwort)\n", importResult.UsersRestored)
|
||
fmt.Printf(" %d Einstellungen\n", importResult.SettingsRestored)
|
||
|
||
if len(importResult.SkippedExisting) > 0 {
|
||
fmt.Printf("\n %d Objekt(e) waren bereits vorhanden und wurden NICHT überschrieben:\n",
|
||
len(importResult.SkippedExisting))
|
||
|
||
for _, skippedObject := range importResult.SkippedExisting {
|
||
fmt.Printf(" – %s\n", skippedObject)
|
||
}
|
||
}
|
||
}
|
||
|
||
// printNextSteps schreibt die verbleibenden Handgriffe.
|
||
//
|
||
// Sie stehen am Ende der Ausgabe, weil sie das sind, was der Betreiber als
|
||
// naechstes tut. Eine Wiederherstellung, die mit „fertig" endet und einen
|
||
// halben Tag spaeter an einem angehaltenen Auftrag scheitert, ist keine.
|
||
func printNextSteps(importResult *disasterrecovery.ImportResult, catalogImported bool) {
|
||
fmt.Println("\nWas jetzt noch zu tun ist:")
|
||
fmt.Println(" 1. Ersten Zugang anlegen: syncova-admin create-admin --username <name>")
|
||
fmt.Println(" 2. Pfade der Repositories prüfen und sie auf 'aktiv' setzen")
|
||
|
||
if !catalogImported {
|
||
fmt.Println(" 3. Wiederherstellungspunkte übernehmen: syncova-dr restore --repo <pfad> --catalog")
|
||
}
|
||
|
||
fmt.Println(" 4. Aufträge einzeln prüfen und wieder freigeben")
|
||
fmt.Println(" 5. Benachrichtigungswege neu einrichten und einschalten")
|
||
fmt.Println(" 6. Vor der ersten Sicherung eine Prüfung anstoßen — die Wiederherstellbarkeit")
|
||
fmt.Println(" der übernommenen Punkte ist NICHT belegt: sie wurde nicht neu nachgewiesen.")
|
||
|
||
if len(importResult.ManualStepsRequired) > 0 {
|
||
fmt.Println("\nAus dem Sicherungssatz:")
|
||
|
||
for _, manualStep := range importResult.ManualStepsRequired {
|
||
fmt.Printf(" – %s\n", wrapForTerminal(manualStep, " "))
|
||
}
|
||
}
|
||
}
|
||
|
||
// commandEnvironment haelt die aufgebauten Dienste.
|
||
type commandEnvironment struct {
|
||
// logger schreibt die Protokollzeilen.
|
||
logger *slog.Logger
|
||
// databasePool ist der Verbindungspool; er muss geschlossen werden.
|
||
databasePool *database.Pool
|
||
}
|
||
|
||
// close gibt die Betriebsmittel frei.
|
||
func (environment *commandEnvironment) close() {
|
||
environment.databasePool.Close()
|
||
}
|
||
|
||
// buildEnvironment laedt Konfiguration und verbindet die Datenbank.
|
||
func buildEnvironment(setupContext context.Context) (*commandEnvironment, error) {
|
||
serviceConfig, configError := config.Load(serviceName)
|
||
if configError != nil {
|
||
return nil, configError
|
||
}
|
||
|
||
commandLogger := logging.New(os.Stderr, logging.Options{
|
||
ServiceName: serviceConfig.ServiceName,
|
||
Level: serviceConfig.Logging.Level,
|
||
Format: serviceConfig.Logging.Format,
|
||
})
|
||
|
||
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
|
||
if databaseError != nil {
|
||
return nil, databaseError
|
||
}
|
||
|
||
return &commandEnvironment{logger: commandLogger, databasePool: databasePool}, nil
|
||
}
|
||
|
||
// wrapForTerminal bricht einen langen Hinweis auf Terminalbreite um.
|
||
//
|
||
// Ohne Umbruch laeuft ein dreizeiliger Hinweis als eine Zeile durch das Fenster
|
||
// und ist genau dann unlesbar, wenn er gebraucht wird.
|
||
func wrapForTerminal(text string, continuationPrefix string) string {
|
||
const lineWidth = 76
|
||
|
||
words := strings.Fields(text)
|
||
if len(words) == 0 {
|
||
return ""
|
||
}
|
||
|
||
var wrappedText strings.Builder
|
||
|
||
currentLineLength := 0
|
||
|
||
for wordIndex, word := range words {
|
||
if currentLineLength > 0 && currentLineLength+len(word)+1 > lineWidth {
|
||
wrappedText.WriteString("\n" + continuationPrefix)
|
||
currentLineLength = 0
|
||
} else if wordIndex > 0 {
|
||
wrappedText.WriteString(" ")
|
||
currentLineLength++
|
||
}
|
||
|
||
wrappedText.WriteString(word)
|
||
currentLineLength += len(word)
|
||
}
|
||
|
||
return wrappedText.String()
|
||
}
|
||
|
||
// isVersionArgument erkennt eine Versionsabfrage.
|
||
func isVersionArgument(argument string) bool {
|
||
return argument == "version" || argument == "--version" || argument == "-version"
|
||
}
|