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>
664 lines
22 KiB
Go
664 lines
22 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
)
|
|
|
|
// LocalRepository ist ein Repository im lokalen Dateisystem.
|
|
//
|
|
// Es ist zugleich die Grundlage des gehärteten Repositorys: der Unterschied
|
|
// liegt allein in den Schutzmaßnahmen beim Commit und beim Löschen, nicht im
|
|
// Format (PROMPT.md §15).
|
|
type LocalRepository struct {
|
|
// rootPath ist das Wurzelverzeichnis des Repositorys.
|
|
rootPath string
|
|
// descriptor beschreibt das Repository.
|
|
descriptor Descriptor
|
|
// logger protokolliert Vorgänge des Repositorys.
|
|
logger *slog.Logger
|
|
// lockHandle hält die Sperrdatei offen, solange geschrieben werden darf.
|
|
lockHandle *os.File
|
|
// catalogMutex schützt den Katalog vor gleichzeitiger Änderung im Prozess.
|
|
catalogMutex sync.Mutex
|
|
// timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar.
|
|
timeSource func() time.Time
|
|
}
|
|
|
|
// CreateOptions beschreiben ein neu anzulegendes Repository.
|
|
type CreateOptions struct {
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string
|
|
// Kind ist die Betriebsart.
|
|
Kind RepositoryKind
|
|
// Immutable aktiviert den Aufbewahrungsschutz.
|
|
Immutable bool
|
|
// Retention ist die Aufbewahrungsfrist neuer Backups.
|
|
//
|
|
// Null bedeutet die Standardfrist von 30 Tagen.
|
|
Retention time.Duration
|
|
// MinimumRetention ist die kürzeste je zulässige Frist.
|
|
//
|
|
// Sie lässt sich später nicht mehr verringern. Ohne diese Sperre wäre der
|
|
// Aufbewahrungsschutz eine Einstellung — und eine Einstellung, die der
|
|
// Angreifer ändern kann, ist kein Schutz.
|
|
MinimumRetention time.Duration
|
|
// EncryptionRequired verlangt verschlüsselte Ablage.
|
|
EncryptionRequired bool
|
|
// CreatedByVersion ist die anlegende Programmversion.
|
|
CreatedByVersion string
|
|
}
|
|
|
|
// Create legt ein neues Repository an.
|
|
//
|
|
// Der Descriptor wird zuletzt geschrieben: bricht der Vorgang vorher ab, bleibt
|
|
// ein unvollständiges Verzeichnis zurück, das beim Öffnen nicht als gültiges
|
|
// Repository durchgeht.
|
|
func Create(createContext context.Context, rootPath string, createOptions CreateOptions, baseLogger *slog.Logger) (*LocalRepository, error) {
|
|
absoluteRootPath, pathError := filepath.Abs(rootPath)
|
|
if pathError != nil {
|
|
return nil, fmt.Errorf("der repository-pfad konnte nicht aufgelöst werden: %w", pathError)
|
|
}
|
|
|
|
// Ein bestehendes Repository darf niemals überschrieben werden.
|
|
descriptorPath := filepath.Join(absoluteRootPath, directoryFormat, fileDescriptor)
|
|
if _, statError := os.Stat(descriptorPath); statError == nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrRepositoryExists, absoluteRootPath)
|
|
}
|
|
|
|
// Das Wurzelverzeichnis bekommt dieselben Rechte wie alles darunter.
|
|
//
|
|
// MkdirAll legt Elternverzeichnisse mit der umask des Aufrufers an — die
|
|
// Wurzel eines frisch erzeugten Repositorys war dadurch weltlesbar,
|
|
// waehrend jedes Unterverzeichnis 0700 trug. Ein Fremder kam zwar in
|
|
// keines hinein, sah aber, dass hier ein Repository liegt und wie viele
|
|
// Backups es fuehrt. In Phase 19 aufgefallen.
|
|
if directoryError := ensureDirectory(absoluteRootPath); directoryError != nil {
|
|
return nil, directoryError
|
|
}
|
|
|
|
if permissionError := os.Chmod(absoluteRootPath, directoryPermissions); permissionError != nil {
|
|
return nil, fmt.Errorf("die rechte des repositorys konnten nicht gesetzt werden: %w",
|
|
permissionError)
|
|
}
|
|
|
|
for _, repositoryDirectory := range allRepositoryDirectories {
|
|
if directoryError := ensureDirectory(filepath.Join(absoluteRootPath, repositoryDirectory)); directoryError != nil {
|
|
return nil, directoryError
|
|
}
|
|
}
|
|
|
|
repositoryKind := createOptions.Kind
|
|
if repositoryKind == "" {
|
|
repositoryKind = KindLocal
|
|
}
|
|
|
|
descriptor := Descriptor{
|
|
Identifier: FormatIdentifier,
|
|
FormatVersion: FormatVersion,
|
|
RepositoryID: uuid.NewString(),
|
|
Name: createOptions.Name,
|
|
Kind: repositoryKind,
|
|
HashAlgorithm: HashAlgorithmSHA256,
|
|
ChunkFanoutDepth: chunkFanoutDepth,
|
|
// Ein gehärtetes Repository ist immer unveränderlich; die Betriebsart
|
|
// wäre sonst wirkungslos.
|
|
Immutable: createOptions.Immutable || repositoryKind == KindHardenedLinux,
|
|
RetentionSeconds: int64(createOptions.Retention.Seconds()),
|
|
MinimumRetentionSeconds: int64(createOptions.MinimumRetention.Seconds()),
|
|
EncryptionRequired: createOptions.EncryptionRequired,
|
|
CreatedAt: time.Now().UTC(),
|
|
CreatedByVersion: createOptions.CreatedByVersion,
|
|
}
|
|
|
|
encodedDescriptor, encodeError := encodeDescriptor(descriptor)
|
|
if encodeError != nil {
|
|
return nil, encodeError
|
|
}
|
|
|
|
if writeError := writeFileAtomically(descriptorPath, encodedDescriptor, dataFilePermissions); writeError != nil {
|
|
return nil, fmt.Errorf("der repository-descriptor konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
// Der Descriptor ist der Einstiegspunkt jedes Wiederaufbaus. Fehlt er, sind
|
|
// Manifeste und Blöcke zwar noch da, aber nicht mehr als Repository zu
|
|
// erkennen — im Angriffsversuch überlebten die Daten und der Descriptor
|
|
// nicht, womit das Repository trotzdem verloren war.
|
|
if descriptor.Immutable {
|
|
if protectError := protectManifestFile(descriptorPath); protectError != nil {
|
|
repositoryLoggerForProtection := logging.WithComponent(baseLogger, "repository")
|
|
repositoryLoggerForProtection.Warn("der descriptor konnte nicht gegen löschung geschützt werden",
|
|
slog.String("grund", protectError.Error()))
|
|
}
|
|
}
|
|
|
|
repositoryLogger := logging.WithComponent(baseLogger, "repository")
|
|
repositoryLogger.Info("repository angelegt",
|
|
slog.String("path", absoluteRootPath),
|
|
slog.String("repository_id", descriptor.RepositoryID),
|
|
slog.String("kind", string(descriptor.Kind)),
|
|
slog.Bool("immutable", descriptor.Immutable),
|
|
slog.Duration("aufbewahrungsfrist", descriptor.RetentionPeriod()),
|
|
slog.Duration("mindestfrist", descriptor.MinimumRetentionPeriod()),
|
|
)
|
|
|
|
localRepository := &LocalRepository{
|
|
rootPath: absoluteRootPath,
|
|
descriptor: descriptor,
|
|
logger: repositoryLogger,
|
|
timeSource: time.Now,
|
|
}
|
|
|
|
// Das neu angelegte Repository wird beschreibbar zurückgegeben: wer es
|
|
// erzeugt, will es unmittelbar befüllen. Ohne Sperre müsste der Aufrufer es
|
|
// sofort wieder schließen und erneut öffnen.
|
|
if lockError := localRepository.acquireLock(); lockError != nil {
|
|
return nil, lockError
|
|
}
|
|
|
|
// Ein leerer Katalog macht das Repository von Anfang an vollständig.
|
|
emptyCatalog := &Catalog{
|
|
CatalogVersion: CatalogVersion,
|
|
RepositoryID: descriptor.RepositoryID,
|
|
Entries: []CatalogEntry{},
|
|
GeneratedAt: time.Now().UTC(),
|
|
}
|
|
|
|
if catalogError := localRepository.writeCatalog(emptyCatalog); catalogError != nil {
|
|
return nil, catalogError
|
|
}
|
|
|
|
return localRepository, nil
|
|
}
|
|
|
|
// OpenOptions steuern das Öffnen eines Repositorys.
|
|
type OpenOptions struct {
|
|
// ReadOnly öffnet das Repository ohne Schreibsperre.
|
|
//
|
|
// Damit können mehrere Vorgänge gleichzeitig lesen, etwa für eine
|
|
// Wiederherstellung während ein Backup läuft.
|
|
ReadOnly bool
|
|
}
|
|
|
|
// Open öffnet ein bestehendes Repository.
|
|
//
|
|
// Das Format wird geprüft, bevor irgendetwas geschrieben wird: ein Repository
|
|
// unbekannter Version darf nicht angetastet werden.
|
|
func Open(openContext context.Context, rootPath string, openOptions OpenOptions, baseLogger *slog.Logger) (*LocalRepository, error) {
|
|
absoluteRootPath, pathError := filepath.Abs(rootPath)
|
|
if pathError != nil {
|
|
return nil, fmt.Errorf("der repository-pfad konnte nicht aufgelöst werden: %w", pathError)
|
|
}
|
|
|
|
descriptorPath := filepath.Join(absoluteRootPath, directoryFormat, fileDescriptor)
|
|
|
|
rawDescriptor, readError := os.ReadFile(descriptorPath)
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w: %s", ErrNotARepository, absoluteRootPath)
|
|
}
|
|
|
|
return nil, fmt.Errorf("der repository-descriptor konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
descriptor, decodeError := decodeDescriptor(rawDescriptor)
|
|
if decodeError != nil {
|
|
return nil, decodeError
|
|
}
|
|
|
|
if validationError := descriptor.Validate(); validationError != nil {
|
|
return nil, validationError
|
|
}
|
|
|
|
localRepository := &LocalRepository{
|
|
rootPath: absoluteRootPath,
|
|
descriptor: descriptor,
|
|
logger: logging.WithComponent(baseLogger, "repository"),
|
|
timeSource: time.Now,
|
|
}
|
|
|
|
// Verzeichnisse, die eine ältere Formatversion noch nicht kannte, werden
|
|
// beim Öffnen ergänzt.
|
|
if !openOptions.ReadOnly {
|
|
for _, repositoryDirectory := range allRepositoryDirectories {
|
|
if directoryError := ensureDirectory(filepath.Join(absoluteRootPath, repositoryDirectory)); directoryError != nil {
|
|
return nil, directoryError
|
|
}
|
|
}
|
|
|
|
if lockError := localRepository.acquireLock(); lockError != nil {
|
|
return nil, lockError
|
|
}
|
|
}
|
|
|
|
localRepository.logger.Info("repository geöffnet",
|
|
slog.String("path", absoluteRootPath),
|
|
slog.String("repository_id", descriptor.RepositoryID),
|
|
slog.Bool("read_only", openOptions.ReadOnly),
|
|
)
|
|
|
|
return localRepository, nil
|
|
}
|
|
|
|
// Descriptor beschreibt das Repository.
|
|
func (localRepository *LocalRepository) Descriptor() Descriptor {
|
|
return localRepository.descriptor
|
|
}
|
|
|
|
// RootPath liefert das Wurzelverzeichnis des Repositorys.
|
|
func (localRepository *LocalRepository) RootPath() string {
|
|
return localRepository.rootPath
|
|
}
|
|
|
|
// Close gibt die Schreibsperre frei.
|
|
func (localRepository *LocalRepository) Close() error {
|
|
return localRepository.releaseLock()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chunk-Ablage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// computeChunkIdentifier bildet die Kennung eines Datenblocks.
|
|
//
|
|
// Die Kennung ist der Inhaltshash. Daraus folgt unmittelbar die Deduplizierung:
|
|
// gleicher Inhalt ergibt dieselbe Kennung und damit denselben Ablageort.
|
|
func computeChunkIdentifier(chunkData []byte) string {
|
|
chunkDigest := sha256.Sum256(chunkData)
|
|
return hex.EncodeToString(chunkDigest[:])
|
|
}
|
|
|
|
// HasChunk meldet, ob ein Chunk bereits vorliegt.
|
|
func (localRepository *LocalRepository) HasChunk(queryContext context.Context, chunkIdentifier string) (bool, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return false, pathError
|
|
}
|
|
|
|
_, statError := os.Stat(targetPath)
|
|
if statError == nil {
|
|
return true, nil
|
|
}
|
|
|
|
if errors.Is(statError, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
|
|
return false, fmt.Errorf("der chunk konnte nicht geprüft werden: %w", statError)
|
|
}
|
|
|
|
// writeChunk legt einen Datenblock ab.
|
|
//
|
|
// Der zweite Rückgabewert meldet, ob der Chunk neu war. Ein bereits vorhandener
|
|
// Chunk wird nicht erneut geschrieben — Inhaltsadressierung macht das überflüssig.
|
|
func (localRepository *LocalRepository) writeChunk(writeContext context.Context, chunkData []byte) (string, bool, error) {
|
|
chunkIdentifier := computeChunkIdentifier(chunkData)
|
|
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return "", false, pathError
|
|
}
|
|
|
|
// Liegt der Inhalt bereits vor, ist nichts zu tun.
|
|
if _, statError := os.Stat(targetPath); statError == nil {
|
|
return chunkIdentifier, false, nil
|
|
}
|
|
|
|
if directoryError := ensureDirectory(filepath.Dir(targetPath)); directoryError != nil {
|
|
return "", false, directoryError
|
|
}
|
|
|
|
if writeError := writeFileAtomically(targetPath, chunkData, dataFilePermissions); writeError != nil {
|
|
return "", false, fmt.Errorf("der chunk konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
localRepository.protectStoredChunk(targetPath)
|
|
|
|
return chunkIdentifier, true, nil
|
|
}
|
|
|
|
// ReadChunk liest einen Chunk und prüft dabei seine Unversehrtheit.
|
|
//
|
|
// Die Prüfung erfolgt bei jedem Lesen, nicht nur beim Integritätslauf: ein
|
|
// beschädigter Chunk darf niemals unbemerkt in eine Wiederherstellung fließen
|
|
// (PROMPT.md §14).
|
|
func (localRepository *LocalRepository) ReadChunk(readContext context.Context, chunkIdentifier string) ([]byte, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return nil, pathError
|
|
}
|
|
|
|
chunkData, readError := os.ReadFile(targetPath)
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier)
|
|
}
|
|
|
|
return nil, fmt.Errorf("der chunk konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
// Der neu berechnete Hash muss der Kennung entsprechen.
|
|
actualIdentifier := computeChunkIdentifier(chunkData)
|
|
if actualIdentifier != chunkIdentifier {
|
|
localRepository.logger.Error("beschädigter chunk erkannt",
|
|
slog.String("expected_id", chunkIdentifier),
|
|
slog.String("actual_id", actualIdentifier),
|
|
slog.String("path", targetPath),
|
|
)
|
|
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkIdentifier)
|
|
}
|
|
|
|
return chunkData, nil
|
|
}
|
|
|
|
// OpenChunk öffnet einen Chunk als Datenstrom.
|
|
//
|
|
// Anders als ReadChunk prüft dieser Weg die Prüfsumme nicht vorab — das würde
|
|
// den gesamten Inhalt in den Speicher zwingen und damit den Zweck verfehlen
|
|
// (PROMPT.md §80). Der Aufrufer muss beim Lesen selbst mitprüfen.
|
|
func (localRepository *LocalRepository) OpenChunk(readContext context.Context, chunkIdentifier string) (io.ReadCloser, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return nil, pathError
|
|
}
|
|
|
|
chunkFile, openError := os.Open(targetPath)
|
|
if openError != nil {
|
|
if errors.Is(openError, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier)
|
|
}
|
|
|
|
return nil, fmt.Errorf("der chunk konnte nicht geöffnet werden: %w", openError)
|
|
}
|
|
|
|
return &verifyingChunkReader{
|
|
underlyingFile: chunkFile,
|
|
runningDigest: sha256.New(),
|
|
expectedIdentifier: chunkIdentifier,
|
|
}, nil
|
|
}
|
|
|
|
// verifyingChunkReader liest einen Chunk und prüft die Prüfsumme beim Schließen.
|
|
//
|
|
// So bleibt die Integritätsprüfung erhalten, ohne den Inhalt vollständig im
|
|
// Arbeitsspeicher halten zu müssen.
|
|
type verifyingChunkReader struct {
|
|
// underlyingFile ist die geöffnete Chunkdatei.
|
|
underlyingFile *os.File
|
|
// runningDigest berechnet die Prüfsumme während des Lesens mit.
|
|
runningDigest interface {
|
|
io.Writer
|
|
Sum([]byte) []byte
|
|
}
|
|
// expectedIdentifier ist die erwartete Kennung.
|
|
expectedIdentifier string
|
|
// reachedEndOfFile meldet, ob der Strom vollständig gelesen wurde.
|
|
reachedEndOfFile bool
|
|
}
|
|
|
|
// Read liest Daten und aktualisiert dabei die laufende Prüfsumme.
|
|
func (chunkReader *verifyingChunkReader) Read(targetBuffer []byte) (int, error) {
|
|
bytesRead, readError := chunkReader.underlyingFile.Read(targetBuffer)
|
|
|
|
if bytesRead > 0 {
|
|
// Der Fehler von Write kann bei einem Hash nicht auftreten.
|
|
_, _ = chunkReader.runningDigest.Write(targetBuffer[:bytesRead])
|
|
}
|
|
|
|
if errors.Is(readError, io.EOF) {
|
|
chunkReader.reachedEndOfFile = true
|
|
|
|
// Am Ende des Stroms steht die Prüfsumme fest und wird sofort geprüft.
|
|
if verifyError := chunkReader.verifyDigest(); verifyError != nil {
|
|
return bytesRead, verifyError
|
|
}
|
|
}
|
|
|
|
return bytesRead, readError
|
|
}
|
|
|
|
// Close schließt die Datei.
|
|
func (chunkReader *verifyingChunkReader) Close() error {
|
|
return chunkReader.underlyingFile.Close()
|
|
}
|
|
|
|
// verifyDigest vergleicht die berechnete Prüfsumme mit der erwarteten Kennung.
|
|
func (chunkReader *verifyingChunkReader) verifyDigest() error {
|
|
actualIdentifier := hex.EncodeToString(chunkReader.runningDigest.Sum(nil))
|
|
if actualIdentifier != chunkReader.expectedIdentifier {
|
|
return fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkReader.expectedIdentifier)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manifeste
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ReadManifest liest das Manifest eines Backups und prüft seine Unversehrtheit.
|
|
func (localRepository *LocalRepository) ReadManifest(readContext context.Context, backupID string) (*Manifest, error) {
|
|
if validationError := validateBackupIdentifier(backupID); validationError != nil {
|
|
return nil, validationError
|
|
}
|
|
|
|
rawManifest, readError := os.ReadFile(localRepository.manifestPath(backupID))
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrBackupNotFound, backupID)
|
|
}
|
|
|
|
return nil, fmt.Errorf("das manifest konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
manifest, decodeError := decodeManifest(rawManifest)
|
|
if decodeError != nil {
|
|
return nil, decodeError
|
|
}
|
|
|
|
// Ein Manifest ohne gültigen Abschluss beschreibt kein verwendbares Backup.
|
|
if verifyError := VerifyManifest(manifest); verifyError != nil {
|
|
return nil, verifyError
|
|
}
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
// listManifestBackupIDs liest die Kennungen aller vorhandenen Manifeste.
|
|
func (localRepository *LocalRepository) listManifestBackupIDs() ([]string, error) {
|
|
manifestDirectory := filepath.Join(localRepository.rootPath, directoryManifests)
|
|
|
|
directoryEntries, readError := os.ReadDir(manifestDirectory)
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("das manifestverzeichnis konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
backupIDs := make([]string, 0, len(directoryEntries))
|
|
for _, directoryEntry := range directoryEntries {
|
|
if directoryEntry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
entryName := directoryEntry.Name()
|
|
|
|
// Temporäre Dateien eines abgebrochenen Schreibvorgangs werden übergangen.
|
|
if len(entryName) <= len(manifestExtension) || filepath.Ext(entryName) != ".json" {
|
|
continue
|
|
}
|
|
|
|
if !hasSuffix(entryName, manifestExtension) {
|
|
continue
|
|
}
|
|
|
|
backupIDs = append(backupIDs, entryName[:len(entryName)-len(manifestExtension)])
|
|
}
|
|
|
|
sort.Strings(backupIDs)
|
|
|
|
return backupIDs, nil
|
|
}
|
|
|
|
// hasSuffix meldet, ob eine Zeichenkette auf eine bestimmte Endung endet.
|
|
func hasSuffix(fullText string, suffix string) bool {
|
|
return len(fullText) >= len(suffix) && fullText[len(fullText)-len(suffix):] == suffix
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sperre
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// lockInformation beschreibt den Inhaber einer Repository-Sperre.
|
|
type lockInformation struct {
|
|
// ProcessID ist die Kennung des sperrenden Prozesses.
|
|
ProcessID int `json:"process_id"`
|
|
// Hostname ist der Rechner, auf dem der Prozess läuft.
|
|
Hostname string `json:"hostname"`
|
|
// AcquiredAt ist der Zeitpunkt der Sperre in UTC.
|
|
AcquiredAt time.Time `json:"acquired_at"`
|
|
}
|
|
|
|
// acquireLock belegt die Schreibsperre des Repositorys (PROMPT.md §83).
|
|
//
|
|
// Die Sperre verhindert, dass zwei Vorgänge gleichzeitig schreiben und dabei
|
|
// den Katalog oder ein Manifest beschädigen.
|
|
func (localRepository *LocalRepository) acquireLock() error {
|
|
// O_EXCL schlägt fehl, wenn die Datei bereits existiert. Das Anlegen ist
|
|
// damit unteilbar und eignet sich als Sperre.
|
|
lockFile, createError := os.OpenFile(localRepository.lockPath(),
|
|
os.O_CREATE|os.O_EXCL|os.O_WRONLY, dataFilePermissions)
|
|
|
|
if createError != nil {
|
|
if errors.Is(createError, os.ErrExist) {
|
|
return localRepository.describeExistingLock()
|
|
}
|
|
|
|
return fmt.Errorf("die repository-sperre konnte nicht gesetzt werden: %w", createError)
|
|
}
|
|
|
|
hostname, _ := os.Hostname()
|
|
lockDetails := lockInformation{
|
|
ProcessID: os.Getpid(),
|
|
Hostname: hostname,
|
|
AcquiredAt: time.Now().UTC(),
|
|
}
|
|
|
|
// Die Angaben helfen einem Administrator, eine hängengebliebene Sperre
|
|
// einzuordnen, statt vor einer nichtssagenden Datei zu stehen.
|
|
encodedLock, marshalError := jsonMarshalIndent(lockDetails)
|
|
if marshalError == nil {
|
|
_, _ = lockFile.Write(encodedLock)
|
|
}
|
|
|
|
if syncError := lockFile.Sync(); syncError != nil {
|
|
_ = lockFile.Close()
|
|
return fmt.Errorf("die repository-sperre konnte nicht gesichert werden: %w", syncError)
|
|
}
|
|
|
|
localRepository.lockHandle = lockFile
|
|
|
|
return nil
|
|
}
|
|
|
|
// describeExistingLock erklärt eine vorgefundene Sperre.
|
|
func (localRepository *LocalRepository) describeExistingLock() error {
|
|
rawLock, readError := os.ReadFile(localRepository.lockPath())
|
|
if readError != nil {
|
|
return ErrRepositoryLocked
|
|
}
|
|
|
|
var lockDetails lockInformation
|
|
if unmarshalError := jsonUnmarshal(rawLock, &lockDetails); unmarshalError != nil {
|
|
return ErrRepositoryLocked
|
|
}
|
|
|
|
return fmt.Errorf("%w (Prozess %d auf %s, seit %s)",
|
|
ErrRepositoryLocked, lockDetails.ProcessID, lockDetails.Hostname,
|
|
lockDetails.AcquiredAt.Format(time.RFC3339))
|
|
}
|
|
|
|
// releaseLock gibt die Schreibsperre frei.
|
|
func (localRepository *LocalRepository) releaseLock() error {
|
|
if localRepository.lockHandle == nil {
|
|
return nil
|
|
}
|
|
|
|
if closeError := localRepository.lockHandle.Close(); closeError != nil {
|
|
return fmt.Errorf("die repository-sperre konnte nicht geschlossen werden: %w", closeError)
|
|
}
|
|
|
|
localRepository.lockHandle = nil
|
|
|
|
if removeError := os.Remove(localRepository.lockPath()); removeError != nil && !errors.Is(removeError, os.ErrNotExist) {
|
|
return fmt.Errorf("die repository-sperre konnte nicht entfernt werden: %w", removeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BreakLock entfernt eine hängengebliebene Sperre.
|
|
//
|
|
// Der Eingriff ist ausdrücklich manuell: bricht man die Sperre eines noch
|
|
// laufenden Vorgangs, können zwei Schreiber gleichzeitig arbeiten und das
|
|
// Repository beschädigen (PROMPT.md §141).
|
|
func BreakLock(rootPath string) error {
|
|
lockFilePath := filepath.Join(rootPath, fileLock)
|
|
|
|
if removeError := os.Remove(lockFilePath); removeError != nil {
|
|
if errors.Is(removeError, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("die repository-sperre konnte nicht entfernt werden: %w", removeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReadLockHolder beschreibt eine vorgefundene Sperre.
|
|
//
|
|
// Der Rückgabewert ist leer, wenn keine Sperre liegt. Er nennt Prozess, Rechner
|
|
// und Zeitpunkt — die drei Angaben, die man braucht, bevor man eine Sperre
|
|
// aufbricht: Ein Prozess auf einem anderen Rechner läuft womöglich noch, und
|
|
// eine Sperre von vor fünf Minuten ist etwas anderes als eine von vorgestern.
|
|
func ReadLockHolder(rootPath string) (string, error) {
|
|
rawLock, readError := os.ReadFile(filepath.Join(rootPath, fileLock))
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return "", nil
|
|
}
|
|
|
|
if readError != nil {
|
|
return "", fmt.Errorf("die repository-sperre konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
var lockDetails lockInformation
|
|
|
|
if unmarshalError := jsonUnmarshal(rawLock, &lockDetails); unmarshalError != nil {
|
|
// Eine unlesbare Sperrdatei ist trotzdem eine Sperre. Sie als "keine"
|
|
// zu melden wäre der gefährlichste Ausgang: Der Aufrufer bräche sie
|
|
// dann ohne Rückfrage.
|
|
return "unlesbare Sperrdatei", nil
|
|
}
|
|
|
|
return fmt.Sprintf("Prozess %d auf %s, seit %s",
|
|
lockDetails.ProcessID, lockDetails.Hostname,
|
|
lockDetails.AcquiredAt.Format(time.RFC3339)), nil
|
|
}
|