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>
360 lines
13 KiB
Go
360 lines
13 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/syncova/syncova/packages/backupformat"
|
|
)
|
|
|
|
// ExportBackup schreibt ein Backup als portablen Container.
|
|
//
|
|
// Der Container ist in sich geschlossen: er enthält Manifest, Chunk-Verzeichnis
|
|
// und sämtliche Datenblöcke. Ein Empfänger benötigt weder dieses Repository
|
|
// noch eine Datenbank, um ihn zu lesen (PROMPT.md §17: Backup Copy).
|
|
func (localRepository *LocalRepository) ExportBackup(exportContext context.Context, backupID string, outputWriter io.Writer, createdByVersion string) (exportedBytes int64, exportError error) {
|
|
manifest, manifestError := localRepository.ReadManifest(exportContext, backupID)
|
|
if manifestError != nil {
|
|
return 0, manifestError
|
|
}
|
|
|
|
// Die Chunks werden in einer festen Reihenfolge abgelegt, damit ihre
|
|
// Positionen im Verzeichnis stimmen. Die Reihenfolge des Manifests ist die
|
|
// natürliche: sie entspricht dem Lesefluss einer Wiederherstellung.
|
|
orderedChunkIdentifiers := collectOrderedChunkIdentifiers(manifest)
|
|
|
|
// Vor dem Schreiben wird geprüft, ob alle Chunks vorliegen. Ein Container,
|
|
// dem mitten im Schreiben ein Block fehlt, wäre nur noch Ausschuss.
|
|
chunkIndex, indexError := localRepository.buildChunkIndex(exportContext, orderedChunkIdentifiers)
|
|
if indexError != nil {
|
|
return 0, indexError
|
|
}
|
|
|
|
containerHeader := backupformat.ContainerHeader{
|
|
BackupID: manifest.BackupID,
|
|
ChainID: manifest.ChainID,
|
|
ParentBackupID: manifest.ParentBackupID,
|
|
SourceID: manifest.Source.SourceID,
|
|
SourceType: manifest.Source.SourceType,
|
|
CreatedAt: manifest.CompletedAt,
|
|
CreatedByVersion: createdByVersion,
|
|
RepositoryID: localRepository.descriptor.RepositoryID,
|
|
}
|
|
|
|
containerWriter, writerError := backupformat.NewWriter(outputWriter, containerHeader)
|
|
if writerError != nil {
|
|
return 0, writerError
|
|
}
|
|
|
|
// Das Manifest ist der Kern des Containers und deshalb erforderlich.
|
|
if writeError := containerWriter.WriteJSONSection(backupformat.SectionManifest,
|
|
backupformat.FlagRequired, manifest); writeError != nil {
|
|
return 0, writeError
|
|
}
|
|
|
|
if writeError := containerWriter.WriteJSONSection(backupformat.SectionSourceMetadata,
|
|
backupformat.FlagRequired, manifest.Source); writeError != nil {
|
|
return 0, writeError
|
|
}
|
|
|
|
if writeError := containerWriter.WriteJSONSection(backupformat.SectionChunkIndex,
|
|
backupformat.FlagRequired, chunkIndex); writeError != nil {
|
|
return 0, writeError
|
|
}
|
|
|
|
// Der Datenbereich entsteht als Datenstrom: er kann beliebig groß werden
|
|
// und darf nicht im Arbeitsspeicher gehalten werden (PROMPT.md §80).
|
|
streamWriter, streamError := containerWriter.BeginSectionStream(backupformat.SectionDataChunks,
|
|
backupformat.FlagRequired, chunkIndex.TotalStoredBytes())
|
|
if streamError != nil {
|
|
return 0, streamError
|
|
}
|
|
|
|
for _, indexEntry := range chunkIndex.Entries {
|
|
if contextError := exportContext.Err(); contextError != nil {
|
|
return 0, contextError
|
|
}
|
|
|
|
// ReadChunk prüft die Unversehrtheit; ein beschädigter Block gelangt
|
|
// damit gar nicht erst in den Container.
|
|
chunkData, readError := localRepository.ReadChunk(exportContext, indexEntry.Identifier)
|
|
if readError != nil {
|
|
return 0, fmt.Errorf("der export wurde abgebrochen: %w", readError)
|
|
}
|
|
|
|
if _, writeError := streamWriter.Write(chunkData); writeError != nil {
|
|
return 0, writeError
|
|
}
|
|
}
|
|
|
|
if finishError := streamWriter.Finish(); finishError != nil {
|
|
return 0, finishError
|
|
}
|
|
|
|
if closeError := containerWriter.Close(); closeError != nil {
|
|
return 0, closeError
|
|
}
|
|
|
|
localRepository.logger.Info("backup als container exportiert",
|
|
slog.String("backup_id", backupID),
|
|
slog.Int("chunks", len(chunkIndex.Entries)),
|
|
slog.Int64("bytes", containerWriter.BytesWritten()))
|
|
|
|
return containerWriter.BytesWritten(), nil
|
|
}
|
|
|
|
// collectOrderedChunkIdentifiers sammelt die Chunks eines Manifests ohne Wiederholung.
|
|
//
|
|
// Ein mehrfach verwendeter Chunk erscheint nur einmal im Container — genau das
|
|
// ist der Zweck der Deduplizierung, und sie soll auch im Container erhalten bleiben.
|
|
func collectOrderedChunkIdentifiers(manifest *Manifest) []string {
|
|
seenIdentifiers := make(map[string]struct{})
|
|
orderedIdentifiers := make([]string, 0)
|
|
|
|
for _, manifestEntry := range manifest.Entries {
|
|
for _, chunkReference := range manifestEntry.Chunks {
|
|
if _, alreadySeen := seenIdentifiers[chunkReference.Identifier]; alreadySeen {
|
|
continue
|
|
}
|
|
|
|
seenIdentifiers[chunkReference.Identifier] = struct{}{}
|
|
orderedIdentifiers = append(orderedIdentifiers, chunkReference.Identifier)
|
|
}
|
|
}
|
|
|
|
return orderedIdentifiers
|
|
}
|
|
|
|
// buildChunkIndex ermittelt Größe und Position aller Chunks.
|
|
func (localRepository *LocalRepository) buildChunkIndex(buildContext context.Context, chunkIdentifiers []string) (backupformat.ChunkIndex, error) {
|
|
chunkIndex := backupformat.ChunkIndex{
|
|
Entries: make([]backupformat.ChunkIndexEntry, 0, len(chunkIdentifiers)),
|
|
}
|
|
|
|
var currentOffset int64
|
|
|
|
for _, chunkIdentifier := range chunkIdentifiers {
|
|
if contextError := buildContext.Err(); contextError != nil {
|
|
return backupformat.ChunkIndex{}, contextError
|
|
}
|
|
|
|
chunkFilePath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return backupformat.ChunkIndex{}, pathError
|
|
}
|
|
|
|
fileInformation, statError := os.Stat(chunkFilePath)
|
|
if statError != nil {
|
|
// Ein fehlender Chunk bedeutet: das Backup ist unvollständig. Der
|
|
// Export wird abgebrochen, statt einen lückenhaften Container zu
|
|
// erzeugen, der sich als vollständig ausgäbe (PROMPT.md §140).
|
|
return backupformat.ChunkIndex{}, fmt.Errorf(
|
|
"der export ist nicht möglich, das backup ist unvollständig: %w (kennung %s)",
|
|
ErrChunkNotFound, chunkIdentifier)
|
|
}
|
|
|
|
chunkLength := fileInformation.Size()
|
|
|
|
chunkIndex.Entries = append(chunkIndex.Entries, backupformat.ChunkIndexEntry{
|
|
Identifier: chunkIdentifier,
|
|
ContainerOffset: currentOffset,
|
|
StoredLength: chunkLength,
|
|
LogicalLength: chunkLength,
|
|
})
|
|
|
|
currentOffset += chunkLength
|
|
}
|
|
|
|
return chunkIndex, nil
|
|
}
|
|
|
|
// ImportResult beschreibt das Ergebnis eines Container-Imports.
|
|
type ImportResult struct {
|
|
// BackupID ist die Kennung des eingelesenen Backups.
|
|
BackupID string `json:"backup_id"`
|
|
// ChunksImported ist die Zahl neu übernommener Chunks.
|
|
ChunksImported int `json:"chunks_imported"`
|
|
// ChunksAlreadyPresent ist die Zahl bereits vorhandener Chunks.
|
|
//
|
|
// Beim Einlesen mehrerer Container derselben Quelle greift dieselbe
|
|
// Deduplizierung wie beim Sichern.
|
|
ChunksAlreadyPresent int `json:"chunks_already_present"`
|
|
// BytesImported ist die Menge neu geschriebener Daten.
|
|
BytesImported int64 `json:"bytes_imported"`
|
|
// SourceRepositoryID benennt das Ursprungs-Repository.
|
|
SourceRepositoryID string `json:"source_repository_id,omitempty"`
|
|
}
|
|
|
|
// ImportBackup liest einen Container in dieses Repository ein.
|
|
//
|
|
// Der Container wird vollständig geprüft, bevor das Manifest sichtbar wird:
|
|
// Erst wenn alle Abschnitte, Prüfsummen und der Abschlussvermerk stimmen,
|
|
// entsteht ein sichtbares Backup. Ein abgebrochener oder verfälschter Container
|
|
// hinterlässt allenfalls Chunks, aber niemals ein scheinbar gültiges Backup.
|
|
func (localRepository *LocalRepository) ImportBackup(importContext context.Context, inputReader io.Reader) (ImportResult, error) {
|
|
if localRepository.lockHandle == nil {
|
|
return ImportResult{}, fmt.Errorf("das repository wurde nur lesend geöffnet und kann nichts aufnehmen")
|
|
}
|
|
|
|
containerReader, readerError := backupformat.NewReader(inputReader)
|
|
if readerError != nil {
|
|
return ImportResult{}, readerError
|
|
}
|
|
|
|
containerHeader := containerReader.Header()
|
|
|
|
if validationError := validateBackupIdentifier(containerHeader.BackupID); validationError != nil {
|
|
return ImportResult{}, validationError
|
|
}
|
|
|
|
// Ein bereits vorhandenes Backup darf nicht überschrieben werden.
|
|
if _, statError := os.Stat(localRepository.manifestPath(containerHeader.BackupID)); statError == nil {
|
|
return ImportResult{}, fmt.Errorf("zu der kennung %s existiert bereits ein backup", containerHeader.BackupID)
|
|
}
|
|
|
|
importResult := ImportResult{
|
|
BackupID: containerHeader.BackupID,
|
|
SourceRepositoryID: containerHeader.RepositoryID,
|
|
}
|
|
|
|
var importedManifest *Manifest
|
|
var chunkIndex backupformat.ChunkIndex
|
|
var hasChunkIndex bool
|
|
|
|
for {
|
|
if contextError := importContext.Err(); contextError != nil {
|
|
return ImportResult{}, contextError
|
|
}
|
|
|
|
nextSection, sectionError := containerReader.NextSection()
|
|
if errors.Is(sectionError, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if sectionError != nil {
|
|
return ImportResult{}, sectionError
|
|
}
|
|
|
|
switch nextSection.Type {
|
|
case backupformat.SectionManifest:
|
|
parsedManifest, decodeError := decodeManifest(nextSection.Content)
|
|
if decodeError != nil {
|
|
return ImportResult{}, decodeError
|
|
}
|
|
|
|
// Das Manifest wird auf seinen eigenen Abschlussvermerk geprüft -
|
|
// unabhängig davon, dass der Container einen trägt.
|
|
if verifyError := VerifyManifest(parsedManifest); verifyError != nil {
|
|
return ImportResult{}, fmt.Errorf("der container enthält kein gültiges backup: %w", verifyError)
|
|
}
|
|
|
|
importedManifest = parsedManifest
|
|
|
|
case backupformat.SectionChunkIndex:
|
|
if unmarshalError := json.Unmarshal(nextSection.Content, &chunkIndex); unmarshalError != nil {
|
|
return ImportResult{}, fmt.Errorf("das chunk-verzeichnis des containers ist unlesbar: %w", unmarshalError)
|
|
}
|
|
|
|
hasChunkIndex = true
|
|
|
|
case backupformat.SectionDataChunks:
|
|
if !hasChunkIndex {
|
|
// Ohne Verzeichnis liesse sich der Datenbereich nicht aufteilen.
|
|
return ImportResult{}, fmt.Errorf("%w: das chunk-verzeichnis fehlt vor den datenblöcken",
|
|
backupformat.ErrMissingSection)
|
|
}
|
|
|
|
if splitError := localRepository.storeChunksFromSection(importContext,
|
|
nextSection.Content, chunkIndex, &importResult); splitError != nil {
|
|
return ImportResult{}, splitError
|
|
}
|
|
}
|
|
}
|
|
|
|
// Der Abschlussvermerk des Containers entscheidet über die Gültigkeit.
|
|
if !containerReader.IsComplete() {
|
|
return ImportResult{}, backupformat.ErrIncompleteBackup
|
|
}
|
|
|
|
if importedManifest == nil {
|
|
return ImportResult{}, fmt.Errorf("%w: %s", backupformat.ErrMissingSection, backupformat.SectionManifest)
|
|
}
|
|
|
|
// Erst nach allen Prüfungen wird das Manifest sichtbar. Damit gilt auch
|
|
// hier: ein Backup erscheint nur vollständig oder gar nicht.
|
|
encodedManifest, encodeError := encodeManifest(importedManifest)
|
|
if encodeError != nil {
|
|
return ImportResult{}, encodeError
|
|
}
|
|
|
|
manifestPermissions := dataFilePermissions
|
|
if localRepository.descriptor.Immutable {
|
|
manifestPermissions = immutableFilePermissions
|
|
}
|
|
|
|
if writeError := writeFileAtomically(localRepository.manifestPath(importedManifest.BackupID),
|
|
encodedManifest, manifestPermissions); writeError != nil {
|
|
return ImportResult{}, fmt.Errorf("das manifest konnte nicht abgelegt werden: %w", writeError)
|
|
}
|
|
|
|
if catalogError := localRepository.appendToCatalog(importedManifest); catalogError != nil {
|
|
localRepository.logger.Error("der katalog konnte nach dem import nicht aktualisiert werden",
|
|
slog.String("backup_id", importedManifest.BackupID),
|
|
slog.String("error", catalogError.Error()))
|
|
}
|
|
|
|
localRepository.logger.Info("container eingelesen",
|
|
slog.String("backup_id", importResult.BackupID),
|
|
slog.Int("chunks_neu", importResult.ChunksImported),
|
|
slog.Int("chunks_vorhanden", importResult.ChunksAlreadyPresent))
|
|
|
|
return importResult, nil
|
|
}
|
|
|
|
// storeChunksFromSection teilt den Datenbereich anhand des Verzeichnisses auf.
|
|
func (localRepository *LocalRepository) storeChunksFromSection(storeContext context.Context, sectionContent []byte, chunkIndex backupformat.ChunkIndex, importResult *ImportResult) error {
|
|
for _, indexEntry := range chunkIndex.Entries {
|
|
if contextError := storeContext.Err(); contextError != nil {
|
|
return contextError
|
|
}
|
|
|
|
endOffset := indexEntry.ContainerOffset + indexEntry.StoredLength
|
|
|
|
// Eine Positionsangabe außerhalb des Datenbereichs deutet auf einen
|
|
// verfälschten Container hin.
|
|
if indexEntry.ContainerOffset < 0 || endOffset > int64(len(sectionContent)) {
|
|
return fmt.Errorf("das chunk-verzeichnis passt nicht zum datenbereich (kennung %s)",
|
|
indexEntry.Identifier)
|
|
}
|
|
|
|
chunkData := sectionContent[indexEntry.ContainerOffset:endOffset]
|
|
|
|
// Der Inhalt muss zu seiner Kennung passen. Andernfalls wäre entweder
|
|
// der Container verfälscht oder das Verzeichnis falsch - in beiden
|
|
// Fällen darf nichts übernommen werden.
|
|
if computeChunkIdentifier(chunkData) != indexEntry.Identifier {
|
|
return fmt.Errorf("%w: ein chunk des containers passt nicht zu seiner kennung (%s)",
|
|
ErrChunkCorrupted, indexEntry.Identifier)
|
|
}
|
|
|
|
_, wasNew, writeError := localRepository.writeChunk(storeContext, chunkData)
|
|
if writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
if wasNew {
|
|
importResult.ChunksImported++
|
|
importResult.BytesImported += indexEntry.StoredLength
|
|
} else {
|
|
importResult.ChunksAlreadyPresent++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|