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>
675 lines
22 KiB
Go
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
|
|
}
|