syncova-backup/apps/api/cmd/syncova-repo/main.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

675 lines
22 KiB
Go

// Command syncova-repo verwaltet Backup-Repositories von der Kommandozeile.
//
// Das Kommando arbeitet ausschließlich auf dem Repository selbst und benötigt
// keine Datenbank. Genau das ist der Zweck: nach dem Verlust des Control Servers
// muss ein Repository allein mit diesem Werkzeug wieder nutzbar werden
// (PROMPT.md §46, SYNCOVA_ARCHITECTURE.md §11).
//
// Aufruf:
//
// syncova-repo create --path <pfad> [--name <name>] [--hardened]
// syncova-repo info --path <pfad>
// syncova-repo list --path <pfad>
// syncova-repo scan --path <pfad> [--deep]
// syncova-repo rebuild --path <pfad>
// syncova-repo health --path <pfad>
// syncova-repo prune --path <pfad> [--apply]
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"text/tabwriter"
"time"
"github.com/syncova/syncova/packages/backupformat"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/repository"
)
// serviceName benennt das Kommando in den Logs.
const serviceName = "syncova-repo"
// buildVersion wird beim Bauen über -ldflags gesetzt.
var buildVersion = "0.1.0-dev"
func main() {
if runError := run(); runError != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
os.Exit(1)
}
}
// run wertet das Unterkommando aus.
func run() error {
// Die Versionsabfrage steht vor allem anderen: Wer wissen will, welche
// Fassung auf einem Server liegt, hat in dem Moment womöglich keine
// Konfiguration — etwa auf einem frisch ausgepackten Paket.
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
fmt.Printf("%s %s\n", serviceName, buildVersion)
return nil
}
if len(os.Args) < 2 {
return errors.New(usageText())
}
// Das Werkzeug protokolliert nach stderr, damit die Ausgabe auf stdout
// weiterverarbeitet werden kann.
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceName,
Level: "warn",
Format: "text",
})
commandArguments := os.Args[2:]
switch requestedCommand := os.Args[1]; requestedCommand {
case "create":
return runCreate(commandArguments, commandLogger)
case "info":
return runInfo(commandArguments, commandLogger)
case "list":
return runList(commandArguments, commandLogger)
case "scan":
return runScan(commandArguments, commandLogger)
case "rebuild":
return runRebuild(commandArguments, commandLogger)
case "health":
return runHealth(commandArguments, commandLogger)
case "prune":
return runPrune(commandArguments, commandLogger)
case "break-lock":
return runBreakLock(commandArguments)
case "export":
return runExport(commandArguments, commandLogger)
case "import":
return runImport(commandArguments, commandLogger)
case "inspect":
return runInspect(commandArguments)
default:
return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText())
}
}
// usageText beschreibt die Verwendung des Kommandos.
func usageText() string {
return `Verwendung:
syncova-repo create --path <pfad> [--name <name>] [--hardened] Legt ein Repository an
syncova-repo info --path <pfad> Zeigt die Repository-Angaben
syncova-repo list --path <pfad> Listet alle Backups
syncova-repo scan --path <pfad> [--deep] Prüft die Unversehrtheit
syncova-repo rebuild --path <pfad> Baut den Katalog neu auf
syncova-repo health --path <pfad> Zeigt den Zustand
syncova-repo break-lock --path <pfad> Entfernt eine haengende Sperre
syncova-repo prune --path <pfad> [--apply] Entfernt verwaiste Chunks
syncova-repo export --path <pfad> --backup <id> --out <datei> Schreibt ein Backup als Container
syncova-repo import --path <pfad> --in <datei> Liest einen Container ein
syncova-repo inspect --in <datei> Prüft einen Container ohne Import`
}
// parsePathFlag liest den Pfad eines Unterkommandos.
func parsePathFlag(commandName string, commandArguments []string, additionalFlags func(*flag.FlagSet)) (string, error) {
commandFlags := flag.NewFlagSet(commandName, flag.ContinueOnError)
repositoryPath := commandFlags.String("path", "", "Pfad des Repositorys")
if additionalFlags != nil {
additionalFlags(commandFlags)
}
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return "", parseError
}
if *repositoryPath == "" {
return "", errors.New("--path ist erforderlich")
}
return *repositoryPath, nil
}
// runCreate legt ein Repository an.
func runCreate(commandArguments []string, commandLogger *slog.Logger) error {
var repositoryName string
var isHardened bool
repositoryPath, parseError := parsePathFlag("create", commandArguments, func(commandFlags *flag.FlagSet) {
commandFlags.StringVar(&repositoryName, "name", "", "Sprechender Name des Repositorys")
commandFlags.BoolVar(&isHardened, "hardened", false, "Gehärtetes Repository mit Aufbewahrungsschutz")
})
if parseError != nil {
return parseError
}
repositoryKind := repository.KindLocal
if isHardened {
repositoryKind = repository.KindHardenedLinux
}
createdRepository, createError := repository.Create(context.Background(), repositoryPath, repository.CreateOptions{
Name: repositoryName,
Kind: repositoryKind,
CreatedByVersion: buildVersion,
}, commandLogger)
if createError != nil {
return createError
}
defer func() { _ = createdRepository.Close() }()
descriptor := createdRepository.Descriptor()
fmt.Printf("Repository angelegt.\n\n")
fmt.Printf(" Pfad: %s\n", createdRepository.RootPath())
fmt.Printf(" Kennung: %s\n", descriptor.RepositoryID)
fmt.Printf(" Art: %s\n", descriptor.Kind)
fmt.Printf(" Immutable: %s\n", formatBoolean(descriptor.Immutable))
return nil
}
// runInfo zeigt die Angaben eines Repositorys.
func runInfo(commandArguments []string, commandLogger *slog.Logger) error {
repositoryPath, parseError := parsePathFlag("info", commandArguments, nil)
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
descriptor := openedRepository.Descriptor()
fmt.Printf(" Pfad: %s\n", openedRepository.RootPath())
fmt.Printf(" Name: %s\n", descriptor.Name)
fmt.Printf(" Kennung: %s\n", descriptor.RepositoryID)
fmt.Printf(" Formatversion: %d\n", descriptor.FormatVersion)
fmt.Printf(" Art: %s\n", descriptor.Kind)
fmt.Printf(" Hashverfahren: %s\n", descriptor.HashAlgorithm)
fmt.Printf(" Immutable: %s\n", formatBoolean(descriptor.Immutable))
fmt.Printf(" Angelegt am: %s\n", descriptor.CreatedAt.Format(time.RFC3339))
fmt.Printf(" Angelegt mit: %s\n", descriptor.CreatedByVersion)
return nil
}
// runList listet alle Backups eines Repositorys.
func runList(commandArguments []string, commandLogger *slog.Logger) error {
repositoryPath, parseError := parsePathFlag("list", commandArguments, nil)
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
catalogEntries, listError := openedRepository.ListBackups(context.Background())
if listError != nil {
return listError
}
if len(catalogEntries) == 0 {
fmt.Println("Das Repository enthält noch keine Backups.")
return nil
}
outputTable := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(outputTable, "BACKUP\tQUELLE\tART\tABGESCHLOSSEN\tDATEN\tABGELEGT\tSCHUTZ")
for _, catalogEntry := range catalogEntries {
retentionText := "-"
if catalogEntry.ImmutableUntil != nil {
retentionText = catalogEntry.ImmutableUntil.Format("2006-01-02")
}
fmt.Fprintf(outputTable, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
catalogEntry.BackupID,
catalogEntry.SourceName,
catalogEntry.BackupType,
catalogEntry.CompletedAt.Format("2006-01-02 15:04"),
formatBytes(catalogEntry.LogicalBytes),
formatBytes(catalogEntry.StoredBytes),
retentionText,
)
}
return outputTable.Flush()
}
// runScan prüft die Unversehrtheit eines Repositorys.
func runScan(commandArguments []string, commandLogger *slog.Logger) error {
var deepScan bool
repositoryPath, parseError := parsePathFlag("scan", commandArguments, func(commandFlags *flag.FlagSet) {
commandFlags.BoolVar(&deepScan, "deep", false, "Inhalt jedes Chunks neu berechnen (langsam, aber vollständig)")
})
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
scanReport, scanError := openedRepository.Scan(context.Background(), repository.ScanOptions{
VerifyChunkContents: deepScan,
})
if scanError != nil {
return scanError
}
fmt.Printf("Geprüfte Backups: %d\n", scanReport.BackupsChecked)
fmt.Printf("Davon einwandfrei: %d\n", scanReport.BackupsHealthy)
fmt.Printf("Geprüfte Chunks: %d\n", scanReport.ChunksChecked)
if deepScan {
fmt.Printf("Gelesene Daten: %s\n", formatBytes(scanReport.BytesChecked))
}
fmt.Printf("Fehlende Chunks: %d\n", scanReport.MissingChunks)
fmt.Printf("Beschädigte Chunks: %d\n", scanReport.CorruptedChunks)
fmt.Printf("Verwaiste Chunks: %d\n", scanReport.OrphanedChunks)
fmt.Printf("\n%s\n", scanReport.Summary())
if len(scanReport.Findings) > 0 {
fmt.Println("\nBefunde:")
for _, scanFinding := range scanReport.Findings {
fmt.Printf(" [%s] %s\n", scanFinding.Severity, scanFinding.Message)
if scanFinding.RecommendedAction != "" {
fmt.Printf(" Empfehlung: %s\n", scanFinding.RecommendedAction)
}
}
}
// Ein beschädigtes Repository liefert einen Fehlerstatus, damit ein
// Überwachungssystem daran anschlägt (PROMPT.md §140).
if !scanReport.IsHealthy() {
return errors.New("das Repository ist nicht vollständig wiederherstellbar")
}
return nil
}
// runRebuild baut den Katalog neu auf.
func runRebuild(commandArguments []string, commandLogger *slog.Logger) error {
repositoryPath, parseError := parsePathFlag("rebuild", commandArguments, nil)
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(context.Background())
if rebuildError != nil {
return rebuildError
}
fmt.Printf("Der Katalog wurde allein aus den Manifesten neu aufgebaut.\n\n")
fmt.Printf(" Gefundene Backups: %d\n", len(rebuiltCatalog.Entries))
fmt.Printf(" Repository: %s\n", rebuiltCatalog.RepositoryID)
fmt.Printf("\nEs wurde keine Datenbank benötigt.\n")
return nil
}
// runHealth zeigt den Zustand eines Repositorys.
func runHealth(commandArguments []string, commandLogger *slog.Logger) error {
repositoryPath, parseError := parsePathFlag("health", commandArguments, nil)
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
healthReport, healthError := openedRepository.Health(context.Background())
if healthError != nil {
return healthError
}
fmt.Printf(" Zustand: %s\n", healthReport.Status)
fmt.Printf(" Meldung: %s\n", healthReport.Message)
if healthReport.RecommendedAction != "" {
fmt.Printf(" Empfehlung: %s\n", healthReport.RecommendedAction)
}
fmt.Printf(" Backups: %d\n", healthReport.BackupCount)
fmt.Printf(" Kapazität: %s\n", formatBytes(healthReport.CapacityBytes))
fmt.Printf(" Belegt: %s (%.1f %%)\n", formatBytes(healthReport.UsedBytes), healthReport.UsedPercentage())
fmt.Printf(" Frei: %s\n", formatBytes(healthReport.FreeBytes))
fmt.Printf(" Antwortzeit: %.2f ms\n", healthReport.LatencyMilliseconds)
return nil
}
// runPrune entfernt verwaiste Chunks.
func runPrune(commandArguments []string, commandLogger *slog.Logger) error {
var applyChanges bool
repositoryPath, parseError := parsePathFlag("prune", commandArguments, func(commandFlags *flag.FlagSet) {
commandFlags.BoolVar(&applyChanges, "apply", false, "Änderungen tatsächlich ausführen")
})
if parseError != nil {
return parseError
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
// Ohne --apply läuft nur eine Simulation: eine Bereinigung ist potenziell
// datenzerstörend und darf nicht versehentlich passieren (PROMPT.md §141).
removedCount, freedBytes, pruneError := openedRepository.PruneOrphanedChunks(context.Background(), !applyChanges)
if pruneError != nil {
return pruneError
}
if applyChanges {
fmt.Printf("Entfernte Chunks: %d (%s freigegeben)\n", removedCount, formatBytes(freedBytes))
return nil
}
fmt.Printf("Simulation: %d Chunks würden entfernt (%s würden frei).\n", removedCount, formatBytes(freedBytes))
fmt.Println("Zum tatsächlichen Ausführen: --apply ergänzen.")
return nil
}
// formatBytes stellt eine Bytezahl lesbar dar.
func formatBytes(byteCount int64) string {
const unitStep = 1024
if byteCount < unitStep {
return fmt.Sprintf("%d B", byteCount)
}
currentValue := float64(byteCount)
unitNames := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
for _, unitName := range unitNames {
currentValue /= unitStep
if currentValue < unitStep {
return fmt.Sprintf("%.1f %s", currentValue, unitName)
}
}
return fmt.Sprintf("%.1f EiB", currentValue/unitStep)
}
// formatBoolean stellt einen Schalter in Worten dar.
func formatBoolean(flagValue bool) string {
if flagValue {
return "ja"
}
return "nein"
}
// runExport schreibt ein Backup als portablen Container.
func runExport(commandArguments []string, commandLogger *slog.Logger) error {
var backupID string
var outputPath string
repositoryPath, parseError := parsePathFlag("export", commandArguments, func(commandFlags *flag.FlagSet) {
commandFlags.StringVar(&backupID, "backup", "", "Kennung des zu exportierenden Backups")
commandFlags.StringVar(&outputPath, "out", "", "Zieldatei des Containers")
})
if parseError != nil {
return parseError
}
if backupID == "" || outputPath == "" {
return errors.New("--backup und --out sind erforderlich")
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
// Die Zieldatei wird exklusiv angelegt: ein bestehender Container darf
// nicht versehentlich überschrieben werden.
outputFile, createError := os.OpenFile(outputPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if createError != nil {
if errors.Is(createError, os.ErrExist) {
return fmt.Errorf("die datei %q existiert bereits", outputPath)
}
return fmt.Errorf("die zieldatei konnte nicht angelegt werden: %w", createError)
}
exportedBytes, exportError := openedRepository.ExportBackup(context.Background(), backupID, outputFile, buildVersion)
if closeError := outputFile.Close(); closeError != nil && exportError == nil {
exportError = fmt.Errorf("die zieldatei konnte nicht geschlossen werden: %w", closeError)
}
if exportError != nil {
// Ein unvollständiger Container wäre eine Falle: er sähe aus wie ein
// Backup, wäre aber keines.
_ = os.Remove(outputPath)
return exportError
}
fmt.Printf("Backup %s als Container geschrieben.\n\n", backupID)
fmt.Printf(" Datei: %s\n", outputPath)
fmt.Printf(" Größe: %s\n", formatBytes(exportedBytes))
return nil
}
// runImport liest einen Container in ein Repository ein.
func runImport(commandArguments []string, commandLogger *slog.Logger) error {
var inputPath string
repositoryPath, parseError := parsePathFlag("import", commandArguments, func(commandFlags *flag.FlagSet) {
commandFlags.StringVar(&inputPath, "in", "", "Einzulesende Containerdatei")
})
if parseError != nil {
return parseError
}
if inputPath == "" {
return errors.New("--in ist erforderlich")
}
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{}, commandLogger)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
inputFile, openFileError := os.Open(inputPath)
if openFileError != nil {
return fmt.Errorf("die containerdatei konnte nicht geöffnet werden: %w", openFileError)
}
defer func() { _ = inputFile.Close() }()
importResult, importError := openedRepository.ImportBackup(context.Background(), inputFile)
if importError != nil {
return importError
}
fmt.Printf("Container eingelesen.\n\n")
fmt.Printf(" Backup: %s\n", importResult.BackupID)
fmt.Printf(" Neue Chunks: %d (%s)\n", importResult.ChunksImported, formatBytes(importResult.BytesImported))
fmt.Printf(" Bereits vorhanden: %d\n", importResult.ChunksAlreadyPresent)
if importResult.SourceRepositoryID != "" {
fmt.Printf(" Ursprungs-Repo: %s\n", importResult.SourceRepositoryID)
}
return nil
}
// runInspect prüft einen Container, ohne ihn einzulesen.
//
// Der Weg dient der Kontrolle vor einer Wiederherstellung: er beantwortet die
// Frage, ob ein übertragener Container überhaupt brauchbar ist.
func runInspect(commandArguments []string) error {
commandFlags := flag.NewFlagSet("inspect", flag.ContinueOnError)
inputPath := commandFlags.String("in", "", "Zu prüfende Containerdatei")
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *inputPath == "" {
return errors.New("--in ist erforderlich")
}
inputFile, openError := os.Open(*inputPath)
if openError != nil {
return fmt.Errorf("die containerdatei konnte nicht geöffnet werden: %w", openError)
}
defer func() { _ = inputFile.Close() }()
containerReader, readerError := backupformat.NewReader(inputFile)
if readerError != nil {
return readerError
}
containerHeader := containerReader.Header()
fmt.Printf(" Formatversion: %d\n", containerReader.FormatVersion())
fmt.Printf(" Backup: %s\n", containerHeader.BackupID)
fmt.Printf(" Kette: %s\n", containerHeader.ChainID)
if containerHeader.ParentBackupID != "" {
fmt.Printf(" Elternbackup: %s\n", containerHeader.ParentBackupID)
}
fmt.Printf(" Quelle: %s (%s)\n", containerHeader.SourceID, containerHeader.SourceType)
fmt.Printf(" Erzeugt am: %s\n", containerHeader.CreatedAt.Format(time.RFC3339))
fmt.Printf(" Erzeugt mit: %s\n", containerHeader.CreatedByVersion)
if containerHeader.Encryption.IsEncrypted() {
fmt.Printf(" Verschlüsselt: %s (Schlüssel %s)\n",
containerHeader.Encryption.Algorithm, containerHeader.Encryption.KeyVersion)
} else {
fmt.Printf(" Verschlüsselt: nein\n")
}
// Alle Abschnitte werden gelesen, damit sämtliche Prüfsummen geprüft werden.
sectionCount := 0
var totalContentBytes int64
for {
nextSection, sectionError := containerReader.NextSection()
if errors.Is(sectionError, io.EOF) {
break
}
if sectionError != nil {
fmt.Printf("\nDer Container ist NICHT verwendbar: %v\n", sectionError)
return errors.New("der container hat die prüfung nicht bestanden")
}
sectionCount++
totalContentBytes += int64(len(nextSection.Content))
fmt.Printf(" Abschnitt: %-20s %s\n", nextSection.Type, formatBytes(int64(len(nextSection.Content))))
}
fmt.Printf("\n Abschnitte: %d\n", sectionCount)
fmt.Printf(" Inhalt: %s\n", formatBytes(totalContentBytes))
if !containerReader.IsComplete() {
fmt.Println("\nDer Container trägt keinen Abschlussvermerk und beschreibt kein vollständiges Backup.")
return errors.New("der container ist unvollständig")
}
fmt.Printf("\nDer Container ist vollständig und unversehrt (abgeschlossen am %s).\n",
containerReader.Footer().CompletedAt.Format(time.RFC3339))
return nil
}
// isVersionArgument erkennt eine Versionsabfrage.
func isVersionArgument(argument string) bool {
return argument == "version" || argument == "--version" || argument == "-version"
}
// confirmBreakLockVariable gibt das Entfernen einer Sperre frei.
const confirmBreakLockVariable = "SYNCOVA_REPO_CONFIRM_BREAK_LOCK"
// runBreakLock entfernt eine hängengebliebene Repository-Sperre.
//
// Der Eingriff ist ausdrücklich manuell und verlangt eine Bestätigung über die
// Umgebung. Der Grund ist unangenehm: Bricht man die Sperre eines noch
// **laufenden** Vorgangs, arbeiten zwei Schreiber gleichzeitig am selben
// Repository. Das Ergebnis ist keine Fehlermeldung, sondern ein beschädigter
// Bestand — und der fällt erst bei einer Wiederherstellung auf.
//
// Sperren werden deshalb nie automatisch gelöst. Wer dieses Kommando braucht,
// hat einen abgestürzten Vorgang und muss sich vorher vergewissern, dass wirklich
// keiner mehr läuft.
func runBreakLock(commandArguments []string) error {
repositoryPath, parseError := parsePathFlag("break-lock", commandArguments, nil)
if parseError != nil {
return parseError
}
lockHolder, readError := repository.ReadLockHolder(repositoryPath)
if readError != nil {
return readError
}
if lockHolder == "" {
fmt.Println("Auf diesem Repository liegt keine Sperre.")
return nil
}
fmt.Printf("Die Sperre wurde gesetzt von: %s\n", lockHolder)
if os.Getenv(confirmBreakLockVariable) != "ja" {
return fmt.Errorf("das Entfernen einer Sperre kann ein Repository beschädigen, "+
"wenn der zugehörige Vorgang noch läuft.\n"+
"Vergewissern Sie sich, dass kein Sicherungs- oder Wiederherstellungslauf "+
"aktiv ist, und wiederholen Sie den Aufruf mit %s=ja", confirmBreakLockVariable)
}
if breakError := repository.BreakLock(repositoryPath); breakError != nil {
return breakError
}
fmt.Println("Die Sperre wurde entfernt.")
return nil
}