syncova-backup/packages/recovery/executor.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

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
}