// 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 [--name ] [--hardened] // syncova-repo info --path // syncova-repo list --path // syncova-repo scan --path [--deep] // syncova-repo rebuild --path // syncova-repo health --path // syncova-repo prune --path [--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 [--name ] [--hardened] Legt ein Repository an syncova-repo info --path Zeigt die Repository-Angaben syncova-repo list --path Listet alle Backups syncova-repo scan --path [--deep] Prüft die Unversehrtheit syncova-repo rebuild --path Baut den Katalog neu auf syncova-repo health --path Zeigt den Zustand syncova-repo break-lock --path Entfernt eine haengende Sperre syncova-repo prune --path [--apply] Entfernt verwaiste Chunks syncova-repo export --path --backup --out Schreibt ein Backup als Container syncova-repo import --path --in Liest einen Container ein syncova-repo inspect --in 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 }