package main import ( "context" "errors" "flag" "fmt" "log/slog" "os" "os/signal" "syscall" "text/tabwriter" "github.com/google/uuid" "github.com/syncova/syncova/packages/hypervisor" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/database" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/providers" ) // runListClustersCommand zeigt die eingerichteten Verbünde. func runListClustersCommand(commandArguments []string) error { commandFlags := flag.NewFlagSet("clusters", flag.ContinueOnError) if parseError := commandFlags.Parse(commandArguments); parseError != nil { return parseError } commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignalListener() environment, buildError := buildGuestEnvironment(commandContext) if buildError != nil { return buildError } defer environment.close() clusterList, listError := environment.clusterStore.ListClusters(commandContext) if listError != nil { return listError } if len(clusterList) == 0 { fmt.Println("Es ist kein Virtualisierungsverbund eingerichtet.") fmt.Println("Richten Sie einen über POST /api/v1/proxmox/clusters ein.") return nil } outputWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(outputWriter, "KENNUNG\tNAME\tADRESSE\tZUGRIFFSWEG\tZUSTAND") for _, singleCluster := range clusterList { fmt.Fprintf(outputWriter, "%s\t%s\t%s\t%s\t%s\n", singleCluster.ID, singleCluster.Name, singleCluster.APIEndpoint, singleCluster.ArchiveTransport, singleCluster.Status) } return outputWriter.Flush() } // runRestoreGuestCommand stellt einen gesicherten Gast wieder her. // // Das ist das Werkzeug für den verpflichtenden Meilenstein der Phase 7: VM // entdecken → sichern → verifizieren → Test-VM löschen → wiederherstellen → // **booten** → validieren. Die letzten beiden Schritte kann keine Software // belegen, die keinen echten Knoten hat. func runRestoreGuestCommand(commandArguments []string) error { commandFlags := flag.NewFlagSet("restore-guest", flag.ContinueOnError) clusterIdentifier := commandFlags.String("cluster", "", "Kennung des Zielverbunds") repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys") backupIdentifier := commandFlags.String("backup", "", "Kennung des Backups im Repository") targetGuest := commandFlags.String("target-guest", "", "Zielkennung, etwa qemu/900 — ohne Angabe wird der Ursprungsgast überschrieben") targetNode := commandFlags.String("node", "", "Zielknoten; ohne Angabe der Ursprungsknoten") targetStorage := commandFlags.String("storage", "", "Zielspeicher für die Platten") overwriteExisting := commandFlags.Bool("overwrite", false, "Einen vorhandenen Gast überschreiben — vernichtet dessen aktuellen Stand") startAfterRestore := commandFlags.Bool("start", false, "Den Gast nach der Wiederherstellung starten") keepStagedArchive := commandFlags.Bool("keep-archive", false, "Das bereitgestellte Archiv auf dem Knoten liegen lassen") if parseError := commandFlags.Parse(commandArguments); parseError != nil { return parseError } if *clusterIdentifier == "" || *repositoryPath == "" || *backupIdentifier == "" { return errors.New("--cluster, --repository und --backup sind erforderlich") } parsedClusterID, parseError := uuid.Parse(*clusterIdentifier) if parseError != nil { return fmt.Errorf("die verbundkennung %q ist keine gueltige uuid", *clusterIdentifier) } // Überschreiben verlangt eine ausdrückliche Bestätigung. // // Dieselbe Überlegung wie bei der Wiederherstellung von Dateien (Phase 9): // Ein versehentlich gesetztes Kennzeichen in einem Skript darf nicht // genügen, um einen laufenden Gast zu ersetzen. if *overwriteExisting && *targetGuest == "" { fmt.Fprintln(os.Stderr, "WARNUNG: Ohne --target-guest wird der Ursprungsgast überschrieben.\n"+ " Sein aktueller Stand geht dabei verloren.") } commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignalListener() environment, buildError := buildGuestEnvironment(commandContext) if buildError != nil { return buildError } defer environment.close() restoreResult, restoreError := environment.clusterStore.RestoreGuest(commandContext, hypervisor.GuestRestoreRequest{ ClusterID: parsedClusterID, RepositoryPath: *repositoryPath, BackupID: *backupIdentifier, TargetGuestID: *targetGuest, TargetNode: *targetNode, TargetStorageID: *targetStorage, OverwriteExisting: *overwriteExisting, StartAfterRestore: *startAfterRestore, KeepStagedArchive: *keepStagedArchive, ProgressCallback: func(currentProgress providers.RestoreProgress) { fmt.Printf(" [%3.0f %%] %s — %s\n", currentProgress.PercentComplete, currentProgress.Stage, currentProgress.Message) }, }, environment.secretStore, environment.logger) if restoreError != nil { return restoreError } fmt.Printf("\nWiederhergestellt: %s auf Knoten %s\n", restoreResult.GuestID, restoreResult.NodeName) fmt.Printf("Bereitgestellt: %s (%s)\n", formatBytes(restoreResult.BytesStaged), restoreResult.ArchiveVolume) fmt.Printf("Dauer: %s\n", restoreResult.Duration.Round(1e6)) if restoreResult.Started { fmt.Println("Zustand: gestartet") } else { // Nicht gestartet ist kein Mangel, sondern die Vorgabe. Der Satz steht // da, damit niemand auf die laufende Maschine wartet. fmt.Println("Zustand: angehalten — starten Sie ihn, wenn das Netz dafür bereit ist") } for _, singleWarning := range restoreResult.Warnings { fmt.Printf("Hinweis: %s\n", singleWarning) } return nil } // guestCommandEnvironment hält die für die Gastbefehle nötigen Dienste. type guestCommandEnvironment struct { // logger schreibt die Protokollzeilen. logger *slog.Logger // databasePool ist der Verbindungspool; er muss geschlossen werden. databasePool *database.Pool // clusterStore verwaltet die Verbünde. clusterStore *hypervisor.Store // secretStore entschlüsselt die Daten im Repository. secretStore crypto.SecretStore } // close gibt die Betriebsmittel frei. func (environment *guestCommandEnvironment) close() { environment.databasePool.Close() } // buildGuestEnvironment lädt Konfiguration, Datenbank und Schlüsselmaterial. // // Anders als `discover` brauchen diese Befehle die Control Plane: Die // Zugangsdaten des Verbunds liegen verschlüsselt in der Datenbank, und ohne // Schlüsselmaterial lässt sich weder das API-Token entschlüsseln noch das // Backup lesen. func buildGuestEnvironment(setupContext context.Context) (*guestCommandEnvironment, 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, }) secretStore, secretStoreError := crypto.NewLocalSecretStore( serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion) if secretStoreError != nil { return nil, fmt.Errorf("die verschluesselung konnte nicht eingerichtet werden: %w", secretStoreError) } databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger) if databaseError != nil { return nil, databaseError } clusterStore, storeError := hypervisor.NewStore(databasePool.Connections(), secretStore) if storeError != nil { databasePool.Close() return nil, storeError } return &guestCommandEnvironment{ logger: commandLogger, databasePool: databasePool, clusterStore: clusterStore, secretStore: secretStore, }, nil }