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>
191 lines
7.0 KiB
Go
191 lines
7.0 KiB
Go
package recovery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/agent"
|
|
"github.com/syncova/syncova/packages/backupengine"
|
|
"github.com/syncova/syncova/packages/jobs"
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// RepositoryResolver liefert Repository und Backupkennung zu einem Backup.
|
|
//
|
|
// Die Schnittstelle haelt das Wiederherstellungspaket frei von der
|
|
// Auftragsverwaltung: Wo ein Backup liegt, weiss die Control Plane, nicht die
|
|
// Wiederherstellung.
|
|
type RepositoryResolver interface {
|
|
// ResolveBackup liefert Pfad und Kennung eines Backups.
|
|
ResolveBackup(resolveContext context.Context, backupIdentifier uuid.UUID) (repositoryPath string, backupIDInRepository string, resolveError error)
|
|
}
|
|
|
|
// StoreRepositoryResolver loest Backups ueber die Auftragsverwaltung auf.
|
|
type StoreRepositoryResolver struct {
|
|
// jobStore ist die Datenzugriffsschicht der Auftraege.
|
|
jobStore *jobs.PostgresStore
|
|
}
|
|
|
|
// NewStoreRepositoryResolver erzeugt die Aufloesung.
|
|
func NewStoreRepositoryResolver(jobStore *jobs.PostgresStore) *StoreRepositoryResolver {
|
|
return &StoreRepositoryResolver{jobStore: jobStore}
|
|
}
|
|
|
|
// ResolveBackup liefert Pfad und Kennung eines Backups.
|
|
func (resolver *StoreRepositoryResolver) ResolveBackup(resolveContext context.Context, backupIdentifier uuid.UUID) (string, string, error) {
|
|
backupRecord, readError := resolver.jobStore.GetBackup(resolveContext, backupIdentifier)
|
|
if readError != nil {
|
|
return "", "", readError
|
|
}
|
|
|
|
repositoryRecord, repositoryError := resolver.jobStore.GetRepository(resolveContext, backupRecord.RepositoryID)
|
|
if repositoryError != nil {
|
|
return "", "", repositoryError
|
|
}
|
|
|
|
return repositoryRecord.Location, backupRecord.BackupIDInRepository, nil
|
|
}
|
|
|
|
// ExecutorOptions steuern den Wiederherstellungs-Executor.
|
|
type ExecutorOptions struct {
|
|
// SecretStore entschluesselt die Datenschluessel der Repositories.
|
|
SecretStore crypto.SecretStore
|
|
}
|
|
|
|
// RestoreExecutor fuehrt Wiederherstellungen ueber die Backup Engine aus.
|
|
type RestoreExecutor struct {
|
|
// resolver loest Backups auf ihr Repository auf.
|
|
resolver RepositoryResolver
|
|
// options sind die Einstellungen.
|
|
options ExecutorOptions
|
|
// logger protokolliert den Verlauf.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// Sicherstellen, dass die Schnittstelle erfuellt wird.
|
|
var _ Executor = (*RestoreExecutor)(nil)
|
|
|
|
// NewRestoreExecutor erzeugt den Executor.
|
|
func NewRestoreExecutor(resolver RepositoryResolver, executorOptions ExecutorOptions, baseLogger *slog.Logger) (*RestoreExecutor, error) {
|
|
if resolver == nil {
|
|
return nil, errors.New("der executor braucht eine aufloesung fuer backups")
|
|
}
|
|
|
|
return &RestoreExecutor{
|
|
resolver: resolver,
|
|
options: executorOptions,
|
|
logger: logging.WithComponent(baseLogger, "restore-executor"),
|
|
}, nil
|
|
}
|
|
|
|
// Execute fuehrt eine Wiederherstellung aus.
|
|
//
|
|
// Das Repository wird **schreibgeschuetzt** geoeffnet: Eine Wiederherstellung
|
|
// liest nur. Damit kann sie neben einer laufenden Sicherung stattfinden, statt
|
|
// auf deren Schreibsperre zu warten — und im Ernstfall wartet niemand gern.
|
|
func (executor *RestoreExecutor) Execute(executionContext context.Context, executionRequest ExecutionRequest) (ExecutionResult, error) {
|
|
restoreJob := executionRequest.Job
|
|
|
|
restoreLogger := executor.logger.With(
|
|
slog.String("restore_id", restoreJob.ID.String()),
|
|
slog.String("ziel", restoreJob.TargetRef))
|
|
|
|
repositoryPath, backupIDInRepository, resolveError := executor.resolver.ResolveBackup(
|
|
executionContext, restoreJob.BackupID)
|
|
if resolveError != nil {
|
|
return ExecutionResult{}, &ExecutionError{
|
|
Code: "BACKUP_UNKNOWN",
|
|
Message: "Das wiederherzustellende Backup ist nicht auffindbar.",
|
|
Cause: resolveError,
|
|
}
|
|
}
|
|
|
|
openedRepository, openError := repository.Open(executionContext, repositoryPath,
|
|
repository.OpenOptions{ReadOnly: true}, restoreLogger)
|
|
if openError != nil {
|
|
return ExecutionResult{}, &ExecutionError{
|
|
Code: "REPOSITORY_UNAVAILABLE",
|
|
Message: fmt.Sprintf("Das Repository unter %s liess sich nicht oeffnen.", repositoryPath),
|
|
Cause: openError,
|
|
}
|
|
}
|
|
|
|
defer func() { _ = openedRepository.Close() }()
|
|
|
|
// Die Vorabpruefung laeuft **erneut**, unmittelbar vor dem Schreiben. Der
|
|
// Bericht am Auftrag kann Minuten oder Tage alt sein; in der Zwischenzeit
|
|
// kann ein Block verschwunden oder das Ziel gefuellt worden sein.
|
|
validator := NewValidator(openedRepository)
|
|
|
|
validationReport, validationError := validator.Validate(executionContext, ValidationRequest{
|
|
BackupID: backupIDInRepository,
|
|
TargetPath: restoreJob.TargetRef,
|
|
PathPrefix: restoreJob.PathPrefix,
|
|
OverwriteExisting: restoreJob.OverwriteExisting || executionRequest.ResumeAfterPath != "",
|
|
DeepChunkCheck: false,
|
|
})
|
|
if validationError != nil {
|
|
return ExecutionResult{}, &ExecutionError{
|
|
Code: "VALIDATION_FAILED",
|
|
Message: "Die Vorabpruefung liess sich nicht ausfuehren.",
|
|
Cause: validationError,
|
|
}
|
|
}
|
|
|
|
if !validationReport.CanProceed() {
|
|
blockingFindings := validationReport.BlockingFindings()
|
|
|
|
return ExecutionResult{}, &ExecutionError{
|
|
Code: "PRECONDITION_FAILED",
|
|
Message: fmt.Sprintf("Die Wiederherstellung wurde vor dem Schreiben abgelehnt: %s",
|
|
blockingFindings[0].Message),
|
|
}
|
|
}
|
|
|
|
engine := backupengine.NewEngine(openedRepository, executor.options.SecretStore, restoreLogger)
|
|
restoreRunner := agent.NewRestoreRunner(engine, restoreLogger)
|
|
|
|
runResult, restoreError := restoreRunner.RunRestore(executionContext, agent.RestoreRunOptions{
|
|
BackupID: backupIDInRepository,
|
|
TargetPath: restoreJob.TargetRef,
|
|
PathPrefix: restoreJob.PathPrefix,
|
|
OverwriteExisting: restoreJob.OverwriteExisting,
|
|
RestorePermissions: restoreJob.RestorePermissions,
|
|
ResumeAfterPath: executionRequest.ResumeAfterPath,
|
|
EntryRestoredCallback: executionRequest.EntryRestoredCallback,
|
|
})
|
|
if restoreError != nil {
|
|
return ExecutionResult{}, &ExecutionError{
|
|
Code: "RESTORE_FAILED",
|
|
Message: restoreError.Error(),
|
|
Cause: restoreError,
|
|
}
|
|
}
|
|
|
|
executionResult := ExecutionResult{
|
|
BytesRestored: runResult.BytesRestored,
|
|
FilesRestored: int64(runResult.FilesRestored + runResult.DirectoriesCreated + runResult.SymlinksCreated),
|
|
FilesSkipped: int64(runResult.SkippedExisting),
|
|
}
|
|
|
|
if runResult.SkippedExisting > 0 {
|
|
// Ein uebergangenes vorhandenes Objekt bedeutet: Die Wiederherstellung
|
|
// ist unvollstaendig. Der Anwender glaubte sonst, alles sei wieder da.
|
|
executionResult.SkipReasons = append(executionResult.SkipReasons,
|
|
fmt.Sprintf("%d vorhandene Objekte wurden nicht ueberschrieben.", runResult.SkippedExisting))
|
|
}
|
|
|
|
restoreLogger.Info("wiederherstellung ausgefuehrt",
|
|
slog.Int("dateien", runResult.FilesRestored),
|
|
slog.Int("verzeichnisse", runResult.DirectoriesCreated),
|
|
slog.Int("geprueft", runResult.VerifiedFiles),
|
|
slog.Int("uebersprungen_fortsetzung", runResult.SkippedResumed))
|
|
|
|
return executionResult, nil
|
|
}
|