// Command syncova-dr stellt eine Anlage nach einem Totalverlust wieder her // (SYNCOVA_IMPLEMENTATION_PLAN.md §20). // // Das Werkzeug bedient man an dem Tag, an dem nichts mehr da ist. Daraus folgen // drei Eigenschaften, die es von den uebrigen Kommandos unterscheiden: // // „inspect" braucht **keine Datenbank**. Nach einem Totalverlust will man zuerst // sehen, was ueberhaupt noch da ist, bevor man einen Server aufsetzt. // // Jeder Schritt sagt, was er getan hat **und was noch zu tun bleibt**. Wer eine // Anlage aus dem Nichts wiederherstellt, hat keine Betriebsanleitung neben sich // liegen; die Ausgabe muss die Anleitung sein. // // Nichts laeuft von selbst wieder an. Auftraege kommen angehalten zurueck, // Repositories als nicht erreichbar, Benachrichtigungswege abgeschaltet. Eine // Anlage, die nach dem Wiederaufbau um zwei Uhr nachts von selbst auf ein halb // hergestelltes System schreibt, waere schlimmer als eine, die stillsteht. // // Aufruf: // // syncova-dr export --repo Sichert die Konfiguration ins Repository // syncova-dr inspect --repo Zeigt den Sicherungssatz (ohne Datenbank) // syncova-dr restore --repo [--catalog] Spielt Konfiguration und Katalog ein package main import ( "context" "flag" "fmt" "log/slog" "os" "strings" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/disasterrecovery" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/database" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/repository" ) // serviceName benennt das Kommando in den Logs. const serviceName = "syncova-dr" // commandTimeout begrenzt die Laufzeit einer Datenbankoperation. // // Grosszuegig bemessen: Der Katalogaufbau liest jedes Manifest des Repositorys, // und bei einer grossen Anlage sind das viele tausend Dateien. const commandTimeout = 30 * time.Minute // 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 { printUsage() return fmt.Errorf("es wurde kein kommando angegeben") } commandName := os.Args[1] commandArguments := os.Args[2:] switch commandName { case "export": return runExport(commandArguments) case "inspect": return runInspect(commandArguments) case "restore": return runRestore(commandArguments) case "help", "-h", "--help": printUsage() return nil default: printUsage() return fmt.Errorf("unbekanntes kommando %q", commandName) } } // printUsage schreibt die Kurzhilfe. func printUsage() { fmt.Fprintf(os.Stderr, `%s: Verwendung: syncova-dr export --repo Sichert die Konfiguration ins Repository syncova-dr inspect --repo Zeigt den Sicherungssatz (ohne Datenbank) syncova-dr restore --repo [--catalog] Spielt Konfiguration und Katalog ein Der Weg nach einem Totalverlust: 1. syncova-migrate up Schema anlegen 2. syncova-dr inspect --repo Ansehen, was da ist 3. syncova-dr restore --repo --catalog Konfiguration und Wiederherstellungspunkte 4. syncova-admin create-admin --username Ersten Zugang anlegen `, serviceName) } // runExport sichert die Konfiguration ins Repository. func runExport(commandArguments []string) error { exportFlags := flag.NewFlagSet("export", flag.ContinueOnError) repositoryPath := exportFlags.String("repo", "", "Pfad des Repositorys") if parseError := exportFlags.Parse(commandArguments); parseError != nil { return parseError } if *repositoryPath == "" { return fmt.Errorf("--repo ist erforderlich") } commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout) defer cancelCommand() environment, environmentError := buildEnvironment(commandContext) if environmentError != nil { return environmentError } defer environment.close() openRepository, openError := repository.Open(commandContext, *repositoryPath, repository.OpenOptions{}, environment.logger) if openError != nil { return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError) } defer func() { _ = openRepository.Close() }() repositoryDescriptor := openRepository.Descriptor() snapshotExporter := disasterrecovery.NewExporter(environment.databasePool.Connections()) configurationSnapshot, buildError := snapshotExporter.BuildSnapshot(commandContext, repositoryDescriptor.RepositoryID, serviceName, buildVersion) if buildError != nil { return buildError } snapshotPath, writeError := disasterrecovery.WriteSnapshot(openRepository.RootPath(), configurationSnapshot) if writeError != nil { return writeError } fmt.Printf("Konfiguration gesichert: %s\n", snapshotPath) fmt.Printf(" %s\n", configurationSnapshot.Summary()) fmt.Printf(" Schemastand %d, erzeugt %s\n", configurationSnapshot.SchemaVersion, configurationSnapshot.CreatedAt.Format(time.RFC3339)) fmt.Println() fmt.Println("Nicht enthalten (und nach einer Wiederherstellung von Hand zu erledigen):") for _, omission := range configurationSnapshot.OmittedForSecurity { fmt.Printf(" – %s\n", wrapForTerminal(omission, " ")) } return nil } // runInspect zeigt den Sicherungssatz eines Repositorys. // // **Ohne Datenbank.** Das ist der Zweck: Wer nach einem Ausfall vor einem // Repository steht, muss sehen koennen, was darin ist, bevor er entscheidet, wie // er weitermacht. func runInspect(commandArguments []string) error { inspectFlags := flag.NewFlagSet("inspect", flag.ContinueOnError) repositoryPath := inspectFlags.String("repo", "", "Pfad des Repositorys") if parseError := inspectFlags.Parse(commandArguments); parseError != nil { return parseError } if *repositoryPath == "" { return fmt.Errorf("--repo ist erforderlich") } configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath) if readError != nil { return readError } fmt.Println("Sicherungssatz der Konfiguration") fmt.Printf(" Repository: %s\n", configurationSnapshot.RepositoryID) fmt.Printf(" Erzeugt: %s von %s\n", configurationSnapshot.CreatedAt.Format(time.RFC3339), configurationSnapshot.CreatedBy) fmt.Printf(" Programm: %s\n", configurationSnapshot.ProductVersion) fmt.Printf(" Schemastand: %d\n", configurationSnapshot.SchemaVersion) fmt.Printf(" Inhalt: %s\n", configurationSnapshot.Summary()) if len(configurationSnapshot.Repositories) > 0 { fmt.Println("\nRepositories:") for _, repositoryRecord := range configurationSnapshot.Repositories { fmt.Printf(" %-24s %s\n", repositoryRecord.Name, repositoryRecord.Location) } } if len(configurationSnapshot.Jobs) > 0 { fmt.Println("\nAufträge:") for _, jobRecord := range configurationSnapshot.Jobs { fmt.Printf(" %-24s %d Quelle(n), Zeitplan %s\n", jobRecord.Name, len(jobRecord.Sources), jobRecord.ScheduleType) } } fmt.Println("\nNicht enthalten (nach der Wiederherstellung von Hand zu erledigen):") for _, omission := range configurationSnapshot.OmittedForSecurity { fmt.Printf(" – %s\n", wrapForTerminal(omission, " ")) } return nil } // runRestore spielt Konfiguration und Katalog ein. func runRestore(commandArguments []string) error { restoreFlags := flag.NewFlagSet("restore", flag.ContinueOnError) repositoryPath := restoreFlags.String("repo", "", "Pfad des Repositorys") includeCatalog := restoreFlags.Bool("catalog", false, "Wiederherstellungspunkte aus den Manifesten übernehmen") if parseError := restoreFlags.Parse(commandArguments); parseError != nil { return parseError } if *repositoryPath == "" { return fmt.Errorf("--repo ist erforderlich") } commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout) defer cancelCommand() environment, environmentError := buildEnvironment(commandContext) if environmentError != nil { return environmentError } defer environment.close() configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath) if readError != nil { return readError } snapshotImporter := disasterrecovery.NewImporter(environment.databasePool.Connections()) importResult, importError := snapshotImporter.ImportSnapshot(commandContext, configurationSnapshot) if importError != nil { return importError } printImportResult(importResult) if *includeCatalog { if catalogError := restoreCatalog(commandContext, environment, *repositoryPath, configurationSnapshot); catalogError != nil { return catalogError } } else { fmt.Println("\nDie Wiederherstellungspunkte wurden NICHT übernommen (--catalog fehlt).") fmt.Println("Ohne sie kennt die Anlage die vorhandenen Sicherungen nicht.") } printNextSteps(importResult, *includeCatalog) return nil } // restoreCatalog uebernimmt die Wiederherstellungspunkte aus dem Repository. func restoreCatalog(commandContext context.Context, environment *commandEnvironment, repositoryPath string, configurationSnapshot *disasterrecovery.Snapshot) error { openRepository, openError := repository.Open(commandContext, repositoryPath, repository.OpenOptions{}, environment.logger) if openError != nil { return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError) } defer func() { _ = openRepository.Close() }() repositoryDescriptor := openRepository.Descriptor() // Das Repository muss dasselbe sein, das der Sicherungssatz beschreibt. // Andernfalls landeten die Wiederherstellungspunkte unter einer fremden // Repository-Kennung — und ein spaeterer Restore suchte sie am falschen Ort. if repositoryDescriptor.RepositoryID != configurationSnapshot.RepositoryID { return fmt.Errorf("das repository traegt die kennung %s, der sicherungssatz "+ "beschreibt %s", repositoryDescriptor.RepositoryID, configurationSnapshot.RepositoryID) } databaseRepositoryIdentifier, resolveError := resolveRepositoryIdentifier( configurationSnapshot, repositoryDescriptor.RepositoryID) if resolveError != nil { return resolveError } fmt.Println("\nWiederherstellungspunkte werden aus den Manifesten aufgebaut …") catalogImporter := disasterrecovery.NewCatalogImporter(environment.databasePool.Connections()) catalogResult, catalogError := catalogImporter.ImportCatalog(commandContext, openRepository, databaseRepositoryIdentifier) if catalogError != nil { return catalogError } fmt.Printf(" %d Wiederherstellungspunkte übernommen, %d bereits bekannt, %d Ketten angelegt\n", catalogResult.BackupsImported, catalogResult.BackupsAlreadyKnown, catalogResult.ChainsCreated) if len(catalogResult.UnresolvedParents) > 0 { fmt.Printf(" %d Zusatzsicherung(en) ohne auffindbares Elternbackup. Sie bleiben "+ "nutzbar: Syncova-Manifeste sind vollständig.\n", len(catalogResult.UnresolvedParents)) } return nil } // resolveRepositoryIdentifier findet die Datenbankkennung des Repositorys. func resolveRepositoryIdentifier(configurationSnapshot *disasterrecovery.Snapshot, repositoryUUID string) (uuid.UUID, error) { for _, repositoryRecord := range configurationSnapshot.Repositories { if repositoryRecord.RepositoryUUID != repositoryUUID { continue } parsedIdentifier, parseError := uuid.Parse(repositoryRecord.ID) if parseError != nil { return uuid.Nil, fmt.Errorf("die kennung des repositorys %q ist unlesbar: %w", repositoryRecord.Name, parseError) } return parsedIdentifier, nil } return uuid.Nil, fmt.Errorf("der sicherungssatz kennt kein repository mit der kennung %s. "+ "ohne diese zuordnung liessen sich die wiederherstellungspunkte keinem eingerichteten "+ "repository zuordnen", repositoryUUID) } // printImportResult schreibt das Ergebnis der Konfigurationswiederherstellung. func printImportResult(importResult *disasterrecovery.ImportResult) { fmt.Println("Konfiguration wiederhergestellt:") fmt.Printf(" %d Repositories (als 'nicht erreichbar' — prüfen Sie die Pfade)\n", importResult.RepositoriesRestored) fmt.Printf(" %d Aufbewahrungsregeln\n", importResult.RetentionPoliciesRestored) fmt.Printf(" %d Aufträge mit %d Quellen (ANGEHALTEN)\n", importResult.JobsRestored, importResult.JobSourcesRestored) fmt.Printf(" %d Wartungsfenster\n", importResult.MaintenanceWindowsRestored) fmt.Printf(" %d Benachrichtigungswege (ABGESCHALTET — ohne Zugangsdaten)\n", importResult.NotificationChannelsRestored) fmt.Printf(" %d Konten (DEAKTIVIERT — ohne Passwort)\n", importResult.UsersRestored) fmt.Printf(" %d Einstellungen\n", importResult.SettingsRestored) if len(importResult.SkippedExisting) > 0 { fmt.Printf("\n %d Objekt(e) waren bereits vorhanden und wurden NICHT überschrieben:\n", len(importResult.SkippedExisting)) for _, skippedObject := range importResult.SkippedExisting { fmt.Printf(" – %s\n", skippedObject) } } } // printNextSteps schreibt die verbleibenden Handgriffe. // // Sie stehen am Ende der Ausgabe, weil sie das sind, was der Betreiber als // naechstes tut. Eine Wiederherstellung, die mit „fertig" endet und einen // halben Tag spaeter an einem angehaltenen Auftrag scheitert, ist keine. func printNextSteps(importResult *disasterrecovery.ImportResult, catalogImported bool) { fmt.Println("\nWas jetzt noch zu tun ist:") fmt.Println(" 1. Ersten Zugang anlegen: syncova-admin create-admin --username ") fmt.Println(" 2. Pfade der Repositories prüfen und sie auf 'aktiv' setzen") if !catalogImported { fmt.Println(" 3. Wiederherstellungspunkte übernehmen: syncova-dr restore --repo --catalog") } fmt.Println(" 4. Aufträge einzeln prüfen und wieder freigeben") fmt.Println(" 5. Benachrichtigungswege neu einrichten und einschalten") fmt.Println(" 6. Vor der ersten Sicherung eine Prüfung anstoßen — die Wiederherstellbarkeit") fmt.Println(" der übernommenen Punkte ist NICHT belegt: sie wurde nicht neu nachgewiesen.") if len(importResult.ManualStepsRequired) > 0 { fmt.Println("\nAus dem Sicherungssatz:") for _, manualStep := range importResult.ManualStepsRequired { fmt.Printf(" – %s\n", wrapForTerminal(manualStep, " ")) } } } // commandEnvironment haelt die aufgebauten Dienste. type commandEnvironment struct { // logger schreibt die Protokollzeilen. logger *slog.Logger // databasePool ist der Verbindungspool; er muss geschlossen werden. databasePool *database.Pool } // close gibt die Betriebsmittel frei. func (environment *commandEnvironment) close() { environment.databasePool.Close() } // buildEnvironment laedt Konfiguration und verbindet die Datenbank. func buildEnvironment(setupContext context.Context) (*commandEnvironment, error) { serviceConfig, configError := config.Load(serviceName) if configError != nil { return nil, configError } commandLogger := logging.New(os.Stderr, logging.Options{ ServiceName: serviceConfig.ServiceName, Level: serviceConfig.Logging.Level, Format: serviceConfig.Logging.Format, }) databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger) if databaseError != nil { return nil, databaseError } return &commandEnvironment{logger: commandLogger, databasePool: databasePool}, nil } // wrapForTerminal bricht einen langen Hinweis auf Terminalbreite um. // // Ohne Umbruch laeuft ein dreizeiliger Hinweis als eine Zeile durch das Fenster // und ist genau dann unlesbar, wenn er gebraucht wird. func wrapForTerminal(text string, continuationPrefix string) string { const lineWidth = 76 words := strings.Fields(text) if len(words) == 0 { return "" } var wrappedText strings.Builder currentLineLength := 0 for wordIndex, word := range words { if currentLineLength > 0 && currentLineLength+len(word)+1 > lineWidth { wrappedText.WriteString("\n" + continuationPrefix) currentLineLength = 0 } else if wordIndex > 0 { wrappedText.WriteString(" ") currentLineLength++ } wrappedText.WriteString(word) currentLineLength += len(word) } return wrappedText.String() } // isVersionArgument erkennt eine Versionsabfrage. func isVersionArgument(argument string) bool { return argument == "version" || argument == "--version" || argument == "-version" }