syncova-backup/packages/hypervisor/guestrestore.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

364 lines
15 KiB
Go

package hypervisor
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/providers"
"github.com/syncova/syncova/packages/repository"
)
// GuestArchiveEntryPath ist der Pfad des Plattenabbilds im Manifest.
//
// Er muss mit dem des Executors übereinstimmen; die Konstante steht hier, weil
// die Wiederherstellung sonst gegen ein Paket der Ausführungsschicht bauen
// müsste — und die Schichtung läuft in die andere Richtung.
const GuestArchiveEntryPath = "guest/disk-image.vma"
// GuestConfigurationEntryPath ist der Pfad der Gastkonfiguration im Manifest.
const GuestConfigurationEntryPath = "guest/configuration.json"
// GuestRestoreRequest beschreibt eine Wiederherstellung eines Gasts.
type GuestRestoreRequest struct {
// ClusterID ist der Verbund, in den zurückgeschrieben wird.
//
// Nicht zwingend derselbe, aus dem gesichert wurde: Eine Wiederherstellung
// in einen Ersatzverbund ist genau der Fall, für den man Backups anlegt.
ClusterID uuid.UUID
// RepositoryPath ist der Ort des Repositorys.
RepositoryPath string
// BackupID ist die Kennung des Backups im Repository.
BackupID string
// TargetGuestID ist die Zielkennung; leer wählt die nächste freie.
TargetGuestID string
// TargetNode ist der Zielknoten; leer wählt den Ursprungsknoten.
TargetNode string
// TargetStorageID lenkt die Platten auf einen anderen Speicher.
TargetStorageID string
// OverwriteExisting erlaubt das Überschreiben eines vorhandenen Gasts.
//
// Standardmäßig aus. Ein vorhandener Gast unter der Zielkennung ist der
// häufigste Weg, bei einer Wiederherstellung genau das zu vernichten, was
// man retten wollte.
OverwriteExisting bool
// StartAfterRestore startet den Gast nach der Wiederherstellung.
//
// Standardmäßig aus. Eine wiederhergestellte Maschine, die sich mit
// derselben Adresse ins Netz meldet wie das noch laufende Original,
// richtet mehr Schaden an als der Ausfall.
StartAfterRestore bool
// KeepStagedArchive lässt das zurückgeschriebene Archiv auf dem Knoten liegen.
KeepStagedArchive bool
// ProgressCallback meldet den Fortschritt.
ProgressCallback func(providers.RestoreProgress)
}
// GuestRestoreResult beschreibt eine abgeschlossene Wiederherstellung.
type GuestRestoreResult struct {
// GuestID ist die Kennung des wiederhergestellten Gasts.
GuestID string `json:"guest_id"`
// NodeName ist der Knoten, auf dem er liegt.
NodeName string `json:"node_name"`
// ArchiveVolume ist die Volumenkennung des zurückgeschriebenen Archivs.
ArchiveVolume string `json:"archive_volume"`
// BytesStaged ist die auf den Knoten geschriebene Datenmenge.
BytesStaged int64 `json:"bytes_staged"`
// Started meldet, ob der Gast gestartet wurde.
Started bool `json:"started"`
// Warnings sind Hinweise, die die Wiederherstellung nicht verhindert haben.
//
// Der wichtigste: Die Plattenzuordnung der gesicherten Konfiguration wird
// bewusst nicht gesetzt — sie verwiese auf den alten Ort, und die Maschine
// startete nicht.
Warnings []string `json:"warnings,omitempty"`
// Duration ist die Gesamtdauer.
Duration time.Duration `json:"duration"`
}
// RestoreGuest stellt einen gesicherten Gast in einem Verbund wieder her.
//
// Der Weg hat drei Abschnitte, und der mittlere ist der, den es ohne Syncova
// nicht gäbe:
//
// 1. Das Archiv wird aus dem Repository gelesen — entschlüsselt, entpackt,
// jeder Block gegen seine Kennung geprüft.
// 2. Es wird über den Zugriffsweg **auf einen Proxmox-Speicher geschrieben**.
// Die Proxmox-API nimmt zur Wiederherstellung ausschließlich eine
// Volumenkennung entgegen; einen Endpunkt zum Hochladen gibt es nicht.
// 3. Proxmox stellt daraus den Gast her.
//
// Ohne Schritt 2 endete jede Wiederherstellung dort, wo sie anfängt: Das
// Archiv läge unversehrt im Repository und käme nie auf den Knoten.
func (store *Store) RestoreGuest(restoreContext context.Context, restoreRequest GuestRestoreRequest,
secretStore crypto.SecretStore, baseLogger *slog.Logger) (*GuestRestoreResult, error) {
startTime := time.Now()
connectedProvider, openError := store.OpenProvider(restoreContext, restoreRequest.ClusterID, baseLogger)
if openError != nil {
return nil, openError
}
defer func() { _ = connectedProvider.Close() }()
if connectedProvider.ArchiveWriter == nil {
return nil, fmt.Errorf("%w: der eingerichtete zugriffsweg kann nicht schreiben",
ErrArchiveTransportMissing)
}
// Das Repository wird **schreibgeschützt** geöffnet: Eine Wiederherstellung
// liest nur und muss nicht auf die Schreibsperre einer laufenden Sicherung
// warten (dieselbe Entscheidung wie in Phase 9).
openedRepository, repositoryError := repository.Open(restoreContext, restoreRequest.RepositoryPath,
repository.OpenOptions{ReadOnly: true}, baseLogger)
if repositoryError != nil {
return nil, fmt.Errorf("das repository war nicht erreichbar: %w", repositoryError)
}
defer func() { _ = openedRepository.Close() }()
restoreEngine := backupengine.NewEngine(openedRepository, secretStore, baseLogger)
guestConfiguration, configurationError := readGuestConfiguration(restoreContext, restoreEngine, restoreRequest.BackupID)
if configurationError != nil {
return nil, configurationError
}
archiveFileName := guestConfiguration.ArchiveVolumeName
if archiveFileName == "" {
// Ein Backup aus einer früheren Fassung trägt den Namen nicht. Ihn zu
// raten wäre falsch; abzuleiten ist er aber eindeutig.
archiveFileName = "syncova-restore-" + restoreRequest.BackupID + ".vma"
}
targetNode := restoreRequest.TargetNode
if targetNode == "" {
// Ohne ausdrücklichen Zielknoten wird der genommen, auf dem der Gast
// zuletzt lag. Ein beliebiger Knoten wäre eine stille Verschiebung.
targetNode = nodeFromGuestIdentifier(restoreContext, connectedProvider, guestConfiguration.GuestID)
}
if targetNode == "" {
return nil, errors.New("es liess sich kein zielknoten bestimmen; geben sie ihn ausdruecklich an")
}
reportRestoreProgress(restoreRequest.ProgressCallback, "archiv wird auf den knoten geschrieben", 5, archiveFileName)
archiveWriter, volumeIdentifier, createError := connectedProvider.ArchiveWriter.CreateArchive(restoreContext,
targetNode, connectedProvider.Cluster.BackupStorageID, archiveFileName)
if createError != nil {
return nil, fmt.Errorf("das archiv liess sich auf dem knoten nicht anlegen: %w", createError)
}
countingWriter := &byteCountingWriter{target: archiveWriter}
engineResult, restoreError := restoreEngine.Restore(restoreContext, backupengine.RestoreOptions{
BackupID: restoreRequest.BackupID,
Path: GuestArchiveEntryPath,
}, countingWriter)
if restoreError != nil {
// Der angefangene Datenstrom wird geschlossen und das halbe Archiv
// entfernt: Ein unvollständiges Archiv, das Proxmox für ein gültiges
// hält, ergäbe eine Maschine mit halben Daten.
_ = archiveWriter.Close()
store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger)
return nil, fmt.Errorf("das gesicherte archiv liess sich nicht zurueckschreiben: %w", restoreError)
}
// Erst das Close() macht das Archiv gültig — bei SSH steckt darin auch die
// Auswertung des Rückgabewerts der Gegenseite.
if closeError := archiveWriter.Close(); closeError != nil {
store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger)
return nil, fmt.Errorf("das archiv wurde nicht vollstaendig geschrieben: %w", closeError)
}
baseLogger.Info("archiv auf dem knoten bereitgestellt",
slog.String("knoten", targetNode),
slog.String("volumen", volumeIdentifier),
slog.Int64("bytes", countingWriter.written),
slog.Bool("geprueft", engineResult.Verified))
reportRestoreProgress(restoreRequest.ProgressCallback, "gast wird wiederhergestellt", 50, volumeIdentifier)
providerResult, providerRestoreError := connectedProvider.Provider.RestoreVM(restoreContext, providers.RestoreRequest{
TargetKind: restoreTargetKind(restoreRequest),
SourceGuestID: guestConfiguration.GuestID,
TargetGuestID: restoreRequest.TargetGuestID,
TargetHostID: targetNode,
TargetStorageID: restoreRequest.TargetStorageID,
ArchiveReference: volumeIdentifier,
Metadata: guestConfiguration.Metadata,
StartAfterRestore: restoreRequest.StartAfterRestore,
OverwriteExisting: restoreRequest.OverwriteExisting,
ProgressCallback: restoreRequest.ProgressCallback,
})
// Aufgeräumt wird in jedem Fall: Ein liegengebliebenes Archiv füllt den
// Proxmox-Speicher mit einer unverwalteten Kopie, für die keine
// Aufbewahrungsregel gilt.
if !restoreRequest.KeepStagedArchive {
store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger)
}
if providerRestoreError != nil {
return nil, providerRestoreError
}
restoreResult := &GuestRestoreResult{
GuestID: providerResult.GuestID,
NodeName: providerResult.HostID,
ArchiveVolume: volumeIdentifier,
BytesStaged: countingWriter.written,
Started: providerResult.Started,
Warnings: providerResult.Warnings,
Duration: time.Since(startTime),
}
return restoreResult, nil
}
// readGuestConfiguration liest die mitgesicherte Gastbeschreibung.
//
// Ohne sie ließe sich die Maschine zwar mit ihren Daten, aber nicht in ihrer
// Gestalt wiederherstellen — falsche Netzkarte, anderes BIOS, fehlende
// serielle Schnittstelle. Eine bootfähige, aber unbrauchbare VM ist kein
// Restore.
func readGuestConfiguration(readContext context.Context, restoreEngine *backupengine.Engine,
backupIdentifier string) (*GuestConfigurationDocument, error) {
var configurationBuffer bytes.Buffer
if _, restoreError := restoreEngine.Restore(readContext, backupengine.RestoreOptions{
BackupID: backupIdentifier,
Path: GuestConfigurationEntryPath,
}, &configurationBuffer); restoreError != nil {
return nil, fmt.Errorf("die gesicherte gastkonfiguration liess sich nicht lesen: %w", restoreError)
}
var guestConfiguration GuestConfigurationDocument
if decodeError := json.Unmarshal(configurationBuffer.Bytes(), &guestConfiguration); decodeError != nil {
return nil, fmt.Errorf("die gesicherte gastkonfiguration ist unlesbar: %w", decodeError)
}
return &guestConfiguration, nil
}
// GuestConfigurationDocument ist die im Backup abgelegte Gastbeschreibung.
type GuestConfigurationDocument struct {
// GuestID ist die Kennung des Gasts beim Provider.
GuestID string `json:"guest_id"`
// ClusterName ist der Verbund, aus dem er stammt.
ClusterName string `json:"cluster_name"`
// ArchiveVolumeName ist der Dateiname für das Zurückschreiben.
ArchiveVolumeName string `json:"archive_volume_name"`
// Metadata ist die Konfiguration des Gasts.
Metadata *providers.GuestMetadata `json:"metadata"`
// Disks sind die erkannten Platten samt Ausnahmen.
Disks []providers.Disk `json:"disks"`
// CapturedAt ist der Zeitpunkt der Aufnahme.
CapturedAt time.Time `json:"captured_at"`
}
// ExcludedDiskIdentifiers benennt die nicht gesicherten Platten.
//
// Sie gehören in jede Ausgabe einer Wiederherstellung: Die Maschine kommt ohne
// sie zurück, und wer das nicht erfährt, hält sie für vollständig.
func (document GuestConfigurationDocument) ExcludedDiskIdentifiers() []string {
var excludedIdentifiers []string
for _, singleDisk := range document.Disks {
if singleDisk.ExcludedFromBackup {
excludedIdentifiers = append(excludedIdentifiers, singleDisk.Identifier)
}
}
return excludedIdentifiers
}
// removeStagedArchive entfernt das bereitgestellte Archiv vom Knoten.
func (store *Store) removeStagedArchive(removeContext context.Context, connectedProvider *ConnectedProvider,
nodeName string, volumeIdentifier string, baseLogger *slog.Logger) {
if removeError := connectedProvider.Provider.RemoveArchive(removeContext,
nodeName, volumeIdentifier); removeError != nil {
// Ein misslungenes Aufräumen bricht nichts ab: Die Wiederherstellung
// ist die Handlung, auf die es ankommt. Es wird aber genannt — sonst
// füllt sich der Speicher unbemerkt.
baseLogger.Warn("das bereitgestellte archiv liess sich nicht entfernen",
slog.String("volumen", volumeIdentifier),
slog.String("grund", removeError.Error()))
}
}
// nodeFromGuestIdentifier ermittelt den Knoten des ursprünglichen Gasts.
//
// Findet er sich nicht mehr — der Normalfall nach einem Totalverlust —, bleibt
// das Ergebnis leer, und der Aufrufer muss den Zielknoten angeben. Einen
// beliebigen zu nehmen wäre eine stille Verschiebung der Maschine.
func nodeFromGuestIdentifier(lookupContext context.Context, connectedProvider *ConnectedProvider,
guestIdentifier string) string {
foundGuest, lookupError := connectedProvider.Provider.GetVMInfo(lookupContext, guestIdentifier)
if lookupError != nil || foundGuest == nil {
return ""
}
return foundGuest.HostID
}
// restoreTargetKind wählt die Art des Wiederherstellungsziels.
//
// Eine ausdrückliche Zielkennung ohne Überschreibrecht bedeutet: daneben
// stellen, nicht ersetzen. Das ist der Weg, mit dem sich eine Wiederherstellung
// prüfen lässt, ohne das Original anzutasten — und damit der, den ein
// Wiederherstellungstest (Phase 10) auf echter Hardware nehmen würde.
func restoreTargetKind(restoreRequest GuestRestoreRequest) providers.RestoreTargetKind {
if restoreRequest.TargetGuestID != "" && !restoreRequest.OverwriteExisting {
return providers.RestoreAsNewGuest
}
return providers.RestoreToOriginalHost
}
// reportRestoreProgress meldet den Fortschritt, sofern jemand zuhört.
func reportRestoreProgress(progressCallback func(providers.RestoreProgress), stageName string,
percentComplete float64, message string) {
if progressCallback == nil {
return
}
progressCallback(providers.RestoreProgress{
Stage: stageName,
PercentComplete: percentComplete,
Message: message,
})
}
// byteCountingWriter zählt die durchgereichten Bytes.
//
// Die Zahl stammt damit aus dem tatsächlich Geschriebenen und nicht aus einer
// Angabe des Manifests: Eine Kennzahl, die nicht misst, sondern wiederholt,
// bleibt auch dann richtig, wenn nichts ankam.
type byteCountingWriter struct {
// target ist der eigentliche Empfänger.
target interface{ Write([]byte) (int, error) }
// written ist die Zahl übergebener Bytes.
written int64
}
// Write reicht die Daten weiter und zählt mit.
func (countingWriter *byteCountingWriter) Write(sourceBuffer []byte) (int, error) {
writtenCount, writeError := countingWriter.target.Write(sourceBuffer)
countingWriter.written += int64(writtenCount)
return writtenCount, writeError
}