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

258 lines
12 KiB
Go

package repository
import (
"context"
"errors"
"io"
"time"
)
// Fehler der Repository-Schicht.
var (
// ErrChunkNotFound meldet einen nicht vorhandenen Chunk.
//
// Beim Wiederherstellen bedeutet das einen Datenverlust und ist niemals
// stillschweigend zu übergehen (PROMPT.md §14).
ErrChunkNotFound = errors.New("der chunk ist im repository nicht vorhanden")
// ErrChunkCorrupted meldet einen Chunk, dessen Inhalt nicht zu seiner Kennung passt.
ErrChunkCorrupted = errors.New("der chunk ist beschädigt: sein inhalt passt nicht zu seiner prüfsumme")
// ErrInvalidChunkIdentifier meldet eine unbrauchbare Chunk-Kennung.
ErrInvalidChunkIdentifier = errors.New("die chunk-kennung ist ungültig")
// ErrInvalidBackupIdentifier meldet eine unbrauchbare Backup-Kennung.
ErrInvalidBackupIdentifier = errors.New("die backup-kennung ist ungültig")
// ErrBackupNotFound meldet ein nicht vorhandenes Backup.
ErrBackupNotFound = errors.New("das backup ist im repository nicht vorhanden")
// ErrManifestCorrupted meldet ein beschädigtes Manifest.
ErrManifestCorrupted = errors.New("das manifest ist beschädigt")
// ErrBackupIncomplete meldet ein Backup ohne gültigen Abschlussvermerk.
//
// Ein solches Backup gilt als unvollständig und darf niemals als
// erfolgreich dargestellt werden (SYNCOVA_ARCHITECTURE.md §10).
ErrBackupIncomplete = errors.New("das backup besitzt keinen gültigen abschlussvermerk und ist unvollständig")
// ErrRepositoryLocked meldet ein bereits von jemand anderem beschriebenes Repository.
ErrRepositoryLocked = errors.New("das repository wird bereits von einem anderen vorgang beschrieben")
// ErrRetentionLocked meldet den Versuch, geschützte Daten vorzeitig zu löschen.
ErrRetentionLocked = errors.New("das backup steht unter aufbewahrungsschutz und kann noch nicht gelöscht werden")
// ErrLegalHold meldet den Versuch, ein für Beweiszwecke gehaltenes Backup zu löschen.
//
// Getrennt von ErrRetentionLocked, weil die Abhilfe eine andere ist: Eine
// Frist läuft ab, ein Legal Hold muss ausdrücklich aufgehoben werden.
ErrLegalHold = errors.New("das backup wird für beweiszwecke gehalten und kann nicht gelöscht werden")
// ErrRetentionCannotBeShortened meldet den Versuch, einen Schutz zu verkürzen.
ErrRetentionCannotBeShortened = errors.New("eine aufbewahrungsfrist lässt sich verlängern, aber niemals verkürzen")
// ErrSessionClosed meldet die Verwendung einer bereits beendeten Schreibsession.
ErrSessionClosed = errors.New("die schreibsession ist bereits abgeschlossen oder abgebrochen")
)
// ChunkReference beschreibt einen im Repository abgelegten Chunk.
type ChunkReference struct {
// Identifier ist der Inhaltshash und zugleich die Kennung des Chunks.
Identifier string `json:"id"`
// LogicalOffset ist die Position des Chunks im ursprünglichen Datenstrom.
LogicalOffset int64 `json:"offset"`
// LogicalLength ist die Länge der ursprünglichen Daten in Byte.
LogicalLength int64 `json:"length"`
// StoredLength ist die Länge der abgelegten Daten in Byte.
//
// Sie weicht von LogicalLength ab, sobald Kompression oder Verschlüsselung
// im Spiel sind.
StoredLength int64 `json:"stored_length"`
// StoredDigest ist die Prüfsumme der abgelegten Form.
//
// Bei einem verschlüsselten Block lässt sich seine Unversehrtheit nicht
// mehr an der Kennung ablesen — die beschreibt den Klartext. Diese
// Prüfsumme erlaubt es einem Integritätslauf dennoch, ohne Schlüssel zu
// prüfen. Sie bleibt leer, solange der Block untransformiert abliegt.
StoredDigest string `json:"stored_digest,omitempty"`
}
// Writer nimmt die Daten eines Backups auf.
//
// Der Ablauf folgt dem Commit-Protokoll aus SYNCOVA_ARCHITECTURE.md §10:
// Session anlegen, Chunks schreiben, Manifest schreiben und prüfen, Abschluss
// atomar vermerken. Ohne Commit bleibt kein sichtbares Backup zurück.
type Writer interface {
// WriteChunk legt einen Datenblock ab und liefert dessen Kennung.
//
// Liegt derselbe Inhalt bereits vor, wird nichts erneut geschrieben — das
// ist die Deduplizierung (PROMPT.md §10). Der zweite Rückgabewert meldet,
// ob der Chunk neu war.
WriteChunk(writeContext context.Context, chunkData []byte) (chunkReference ChunkReference, wasNew bool, writeError error)
// WriteTransformedChunk legt einen bereits umgewandelten Block ab.
//
// Der Weg für die Backup Engine: Sie komprimiert und verschlüsselt selbst
// und übergibt die fertige Form samt der Kennung des Klartexts. Die
// Klartextlänge muss mitgegeben werden, weil sie sich aus der abgelegten
// Form nicht mehr ermitteln lässt.
//
// **Diese Methode gehört an die Session, nicht ans Repository.** Sie zählt
// mit — und ohne das Mitzählen trägt jedes Manifest eine Statistik von
// null. Genau das war der Fall, bis es im Notfall-Nachweis der Phase 18
// auffiel: Ein wiederhergestelltes Repository meldete für jedes Backup die
// Größe null, weil die Engine am Repository vorbei geschrieben hatte.
WriteTransformedChunk(writeContext context.Context, plaintextIdentifier string,
storedData []byte, plaintextLength int64) (storedDigest string, wasNew bool, writeError error)
// NoteDeduplicatedChunk vermerkt einen Block, der bereits vorlag.
//
// Er wird nicht geschrieben — gelesen, gehasht und erkannt wurde er
// trotzdem. Ohne diesen Vermerk traegt das Manifest eines zweiten Laufs
// ueber unveraenderte Daten eine Statistik von null, und der Nutzen der
// Deduplizierung liesse sich aus dem Repository allein nicht mehr belegen.
NoteDeduplicatedChunk(plaintextLength int64)
// Commit schließt das Backup ab und macht es sichtbar.
Commit(commitContext context.Context, manifest *Manifest) error
// Abort bricht die Session ab und räumt unfertige Daten weg.
Abort(abortContext context.Context) error
// Statistics liefert die bisher erfassten Kennzahlen der Session.
Statistics() SessionStatistics
}
// SessionStatistics sind die Kennzahlen einer Schreibsession (PROMPT.md §10).
type SessionStatistics struct {
// ChunksWritten ist die Zahl tatsächlich geschriebener Chunks.
ChunksWritten int64 `json:"chunks_written"`
// ChunksDeduplicated ist die Zahl der Chunks, die bereits vorlagen.
ChunksDeduplicated int64 `json:"chunks_deduplicated"`
// LogicalBytes ist die Menge der verarbeiteten Ursprungsdaten.
LogicalBytes int64 `json:"logical_bytes"`
// StoredBytes ist die Menge der tatsächlich geschriebenen Daten.
StoredBytes int64 `json:"stored_bytes"`
// DeduplicatedBytes ist die durch Deduplizierung eingesparte Menge.
DeduplicatedBytes int64 `json:"deduplicated_bytes"`
}
// DeduplicationRatio liefert das Verhältnis von Ursprungs- zu abgelegter Datenmenge.
//
// Ein Wert von 3.0 bedeutet: es wurde ein Drittel der Ursprungsmenge abgelegt.
//
// Der zweite Rückgabewert meldet, ob ein endliches Verhältnis überhaupt
// existiert. Das ist nicht der Fall, wenn nichts verarbeitet wurde oder wenn
// jeder Block bereits vorlag — dann wurde nichts abgelegt, und eine Division
// wäre nicht definiert. Ein stillschweigend gelieferter Wert von 0 würde den
// besten aller Fälle als den schlechtesten darstellen (PROMPT.md §138).
func (statistics SessionStatistics) DeduplicationRatio() (ratio float64, isDefined bool) {
if statistics.LogicalBytes == 0 || statistics.StoredBytes == 0 {
return 0, false
}
return float64(statistics.LogicalBytes) / float64(statistics.StoredBytes), true
}
// SavingsPercentage liefert den Anteil eingesparter Daten in Prozent.
//
// Anders als das Verhältnis ist diese Kennzahl immer bestimmbar und damit die
// verlässlichere Angabe für eine Anzeige: 100 % bedeutet, dass jeder Block
// bereits im Repository vorlag.
func (statistics SessionStatistics) SavingsPercentage() float64 {
if statistics.LogicalBytes == 0 {
return 0
}
return float64(statistics.DeduplicatedBytes) / float64(statistics.LogicalBytes) * 100
}
// Repository ist die Ablage der Backup-Nutzdaten.
//
// Die Schnittstelle ist bewusst schmal: eine spätere Umsetzung auf
// S3-kompatiblem Objektspeicher soll ohne Änderung der Backup Engine möglich
// sein (SYNCOVA_ARCHITECTURE.md §4.5).
type Repository interface {
// Descriptor beschreibt das Repository.
Descriptor() Descriptor
// BeginBackup öffnet eine Schreibsession für ein Backup.
BeginBackup(beginContext context.Context, backupID string) (Writer, error)
// ReadChunk liest einen Chunk und prüft dabei seine Unversehrtheit.
ReadChunk(readContext context.Context, chunkIdentifier string) ([]byte, error)
// OpenChunk öffnet einen Chunk als Datenstrom.
//
// Der Weg ist für große Chunks gedacht, die nicht vollständig in den
// Arbeitsspeicher passen sollen (PROMPT.md §80).
OpenChunk(readContext context.Context, chunkIdentifier string) (io.ReadCloser, error)
// HasChunk meldet, ob ein Chunk bereits vorliegt.
HasChunk(queryContext context.Context, chunkIdentifier string) (bool, error)
// ReadManifest liest das Manifest eines Backups.
ReadManifest(readContext context.Context, backupID string) (*Manifest, error)
// ListBackups liefert alle abgeschlossenen Backups.
ListBackups(listContext context.Context) ([]CatalogEntry, error)
// Catalog liefert den Katalog des Repositorys.
Catalog(catalogContext context.Context) (*Catalog, error)
// RebuildCatalog baut den Katalog allein aus den Manifesten neu auf.
RebuildCatalog(rebuildContext context.Context) (*Catalog, error)
// Scan prüft die Unversehrtheit des gesamten Repositorys.
Scan(scanContext context.Context, scanOptions ScanOptions) (*ScanReport, error)
// Health liefert den Zustand des Repositorys.
Health(healthContext context.Context) (HealthReport, error)
// DeleteBackup entfernt ein Backup, sofern kein Aufbewahrungsschutz greift.
DeleteBackup(deleteContext context.Context, backupID string) error
// Close gibt belegte Betriebsmittel frei.
Close() error
}
// ScanOptions steuern einen Integritätslauf.
type ScanOptions struct {
// VerifyChunkContents legt fest, ob jeder Chunk neu gehasht wird.
//
// Ohne diese Prüfung wird nur das Vorhandensein festgestellt. Die
// vollständige Prüfung liest das gesamte Repository und dauert entsprechend.
VerifyChunkContents bool
// ProgressCallback wird während des Laufs mit dem Fortschritt aufgerufen.
ProgressCallback func(scanProgress ScanProgress)
}
// ScanProgress beschreibt den Fortschritt eines Integritätslaufs.
type ScanProgress struct {
// BackupsChecked ist die Zahl der bislang geprüften Backups.
BackupsChecked int
// BackupsTotal ist die Gesamtzahl zu prüfender Backups.
BackupsTotal int
// ChunksChecked ist die Zahl der bislang geprüften Chunks.
ChunksChecked int64
// BytesChecked ist die Menge der bislang gelesenen Daten.
BytesChecked int64
}
// HealthStatus ist der Zustand eines Repositorys (PROMPT.md §93).
type HealthStatus string
const (
// HealthStatusHealthy bedeutet: keine Auffälligkeiten.
HealthStatusHealthy HealthStatus = "healthy"
// HealthStatusWarning bedeutet: ein Problem bahnt sich an.
HealthStatusWarning HealthStatus = "warning"
// HealthStatusDegraded bedeutet: eingeschränkt nutzbar.
HealthStatusDegraded HealthStatus = "degraded"
// HealthStatusCritical bedeutet: Datenverlust oder Unbenutzbarkeit.
HealthStatusCritical HealthStatus = "critical"
)
// HealthReport beschreibt den Zustand eines Repositorys.
type HealthReport struct {
// Status ist der Gesamtzustand.
Status HealthStatus `json:"status"`
// Message erklärt den Zustand verständlich (PROMPT.md §48).
Message string `json:"message"`
// RecommendedAction nennt den nächsten sinnvollen Schritt.
RecommendedAction string `json:"recommended_action,omitempty"`
// CapacityBytes ist die Gesamtkapazität des Datenträgers.
CapacityBytes int64 `json:"capacity_bytes"`
// UsedBytes ist der belegte Speicherplatz.
UsedBytes int64 `json:"used_bytes"`
// FreeBytes ist der freie Speicherplatz.
FreeBytes int64 `json:"free_bytes"`
// BackupCount ist die Zahl abgeschlossener Backups.
BackupCount int `json:"backup_count"`
// LatencyMilliseconds ist die gemessene Antwortzeit eines Testzugriffs.
LatencyMilliseconds float64 `json:"latency_ms"`
// CheckedAt ist der Zeitpunkt der Prüfung in UTC.
CheckedAt time.Time `json:"checked_at"`
}
// UsedPercentage liefert die Belegung in Prozent.
func (report HealthReport) UsedPercentage() float64 {
if report.CapacityBytes == 0 {
return 0
}
return float64(report.UsedBytes) / float64(report.CapacityBytes) * 100
}