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>
364 lines
15 KiB
Go
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
|
|
}
|