package hypervisor import ( "context" "errors" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/providers/proxmox" ) // ConnectedProvider bündelt einen einsatzbereiten Provider mit seinem Verbund. // // Der Provider allein genügt nicht: Wer ein Archiv zurückschreiben will, // braucht auch den Speicher und den Zugriffsweg — und die stehen im Verbund, // nicht im Provider. type ConnectedProvider struct { // Cluster ist der zugehörige Verbund. Cluster *Cluster // Provider ist der verbundene Proxmox-Provider. Provider *proxmox.Provider // ArchiveWriter legt Archive auf dem Knoten ab; nil, wenn der Weg nur liest. ArchiveWriter proxmox.ArchiveWriter } // Close gibt die Verbindung frei. func (connected *ConnectedProvider) Close() error { if connected.Provider == nil { return nil } return connected.Provider.Disconnect() } // OpenProvider baut aus einem Datenbankeintrag einen verbundenen Provider. // // Hier — und nur hier — treffen Zugangsdaten, Zertifikatsbindung und // Zugriffsweg zusammen. Jede andere Stelle im System bekommt einen fertigen // Provider und muss von Tokens nichts wissen. func (store *Store) OpenProvider(openContext context.Context, clusterIdentifier uuid.UUID, baseLogger *slog.Logger) (*ConnectedProvider, error) { clusterRecord, readError := store.GetCluster(openContext, clusterIdentifier) if readError != nil { return nil, readError } credentials, credentialError := store.LoadCredentials(openContext, clusterIdentifier) if credentialError != nil { return nil, credentialError } providerOptions := proxmox.ProviderOptions{ ClientOptions: proxmox.ClientOptions{ BaseURL: clusterRecord.APIEndpoint, APITokenID: clusterRecord.APITokenID, APITokenSecret: credentials.APITokenSecret, TLSFingerprintSHA256: clusterRecord.TLSFingerprint, }, BackupStorageID: clusterRecord.BackupStorageID, KeepArchiveOnNode: clusterRecord.KeepArchiveOnNode, } builtProvider, buildError := proxmox.NewProvider(providerOptions, baseLogger) if buildError != nil { return nil, buildError } // Der Zugriffsweg braucht bei SSH die Pfadauflösung des Providers — deshalb // wird er erst nach dem Provider gebaut und nachträglich gesetzt. archiveTransport, archiveWriter, transportError := buildTransport(clusterRecord, credentials, builtProvider) if transportError != nil { return nil, transportError } builtProvider.SetArchiveTransport(archiveTransport) if connectError := builtProvider.Connect(openContext); connectError != nil { // Der Grund wird unterschieden, weil die Abhilfe eine andere ist: ein // abgelaufenes Token verlangt einen neuen, ein nicht erreichbarer // Verbund einen Blick auf Netz und Dienst. resultStatus := StatusUnreachable var apiError *proxmox.APIError if errors.As(connectError, &apiError) && apiError.IsAuthenticationFailure() { resultStatus = StatusUnauthorized } if recordError := store.RecordConnectionResult(openContext, clusterIdentifier, resultStatus, connectError); recordError != nil { baseLogger.Warn("das pruefergebnis liess sich nicht vermerken", slog.String("grund", recordError.Error())) } return nil, connectError } if recordError := store.RecordConnectionResult(openContext, clusterIdentifier, StatusReachable, nil); recordError != nil { baseLogger.Warn("das pruefergebnis liess sich nicht vermerken", slog.String("grund", recordError.Error())) } return &ConnectedProvider{ Cluster: clusterRecord, Provider: builtProvider, ArchiveWriter: archiveWriter, }, nil } // buildTransport erzeugt den Zugriffsweg zu den Archivdateien. func buildTransport(clusterRecord *Cluster, credentials *ClusterCredentials, builtProvider *proxmox.Provider) (proxmox.ArchiveTransport, proxmox.ArchiveWriter, error) { switch clusterRecord.ArchiveTransport { case TransportLocal: localTransport := proxmox.NewLocalArchiveTransport(clusterRecord.ArchiveMountRoots) return localTransport, localTransport, nil case TransportSSH: sshTransport, buildError := proxmox.NewSSHArchiveTransport(proxmox.SSHTransportOptions{ Username: clusterRecord.SSHUsername, Port: clusterRecord.SSHPort, PrivateKeyPEM: credentials.SSHPrivateKeyPEM, HostKeyFingerprints: clusterRecord.SSHHostFingerprints, }, builtProvider) if buildError != nil { return nil, nil, buildError } return sshTransport, sshTransport, nil default: return nil, nil, fmt.Errorf("der zugriffsweg %q ist unbekannt", clusterRecord.ArchiveTransport) } } // TestConnection prüft einen Verbund, ohne etwas zu verändern. func (store *Store) TestConnection(testContext context.Context, clusterIdentifier uuid.UUID, baseLogger *slog.Logger) error { connectedProvider, openError := store.OpenProvider(testContext, clusterIdentifier, baseLogger) if openError != nil { return openError } return connectedProvider.Close() } // Discover nimmt den Bestand eines Verbunds auf. // // Die Aufnahme ist eine **Momentaufnahme**, keine Quelle der Wahrheit: Ein Gast // verschwindet aus ihr, weil er gelöscht wurde — oder weil ein Knoten gerade // nicht antwortet. Deshalb wird nichts gelöscht, sondern als fehlend vermerkt. func (store *Store) Discover(discoverContext context.Context, clusterIdentifier uuid.UUID, baseLogger *slog.Logger) (*DiscoveryResult, error) { connectedProvider, openError := store.OpenProvider(discoverContext, clusterIdentifier, baseLogger) if openError != nil { return nil, openError } defer func() { _ = connectedProvider.Close() }() discoveryResult := &DiscoveryResult{ClusterID: clusterIdentifier} foundHosts, hostError := connectedProvider.Provider.ListHosts(discoverContext, "") if hostError != nil { return nil, fmt.Errorf("die knoten liessen sich nicht ermitteln: %w", hostError) } hostIdentifiers, hostSaveError := store.saveHosts(discoverContext, clusterIdentifier, foundHosts) if hostSaveError != nil { return nil, hostSaveError } discoveryResult.HostsFound = len(foundHosts) foundGuests, guestError := connectedProvider.Provider.ListVMs(discoverContext, "") if guestError != nil { return nil, fmt.Errorf("die gaeste liessen sich nicht ermitteln: %w", guestError) } seenGuestIdentifiers := make([]string, 0, len(foundGuests)) for _, foundGuest := range foundGuests { // Die Platten kommen aus einem zweiten Aufruf. Scheitert er, wird der // Gast trotzdem aufgenommen — aber ohne Plattenangabe, und das steht // als Warnung im Ergebnis. Eine Null bei den Platten sähe aus wie eine // Maschine ohne Datenträger. guestDisks, diskError := connectedProvider.Provider.GetVMDisks(discoverContext, foundGuest.Identifier) if diskError != nil { discoveryResult.Warnings = append(discoveryResult.Warnings, fmt.Sprintf("die platten von %s (%s) liessen sich nicht ermitteln: %v", foundGuest.Name, foundGuest.Identifier, diskError)) } guestMetadata, metadataError := connectedProvider.Provider.GetVMMetaData(discoverContext, foundGuest.Identifier) if metadataError != nil { discoveryResult.Warnings = append(discoveryResult.Warnings, fmt.Sprintf("die konfiguration von %s (%s) liess sich nicht lesen: %v", foundGuest.Name, foundGuest.Identifier, metadataError)) } if saveError := store.saveGuest(discoverContext, clusterIdentifier, hostIdentifiers[foundGuest.HostID], foundGuest, guestDisks, guestMetadata); saveError != nil { return nil, saveError } seenGuestIdentifiers = append(seenGuestIdentifiers, foundGuest.Identifier) } discoveryResult.GuestsFound = len(foundGuests) missingCount, missingError := store.markMissingGuests(discoverContext, clusterIdentifier, seenGuestIdentifiers) if missingError != nil { return nil, missingError } discoveryResult.GuestsMissing = missingCount discoveryResult.CompletedAt = time.Now().UTC() if _, updateError := store.connectionPool.Exec(discoverContext, `UPDATE proxmox_clusters SET last_discovery_at = NOW(), updated_at = NOW() WHERE id = $1`, clusterIdentifier); updateError != nil { return nil, fmt.Errorf("der aufnahmezeitpunkt liess sich nicht vermerken: %w", updateError) } baseLogger.Info("bestandsaufnahme abgeschlossen", slog.String("verbund", connectedProvider.Cluster.Name), slog.Int("knoten", discoveryResult.HostsFound), slog.Int("gaeste", discoveryResult.GuestsFound), slog.Int("fehlend", discoveryResult.GuestsMissing), slog.Int("warnungen", len(discoveryResult.Warnings))) return discoveryResult, nil } // ErrArchiveTransportMissing verweist auf den fehlenden Zugriffsweg zu den Archiven. // // Weitergereicht statt neu erfunden: Die Ausführungsschleife soll den Fehler // einordnen können, ohne den Proxmox-Provider zu importieren. Die Schichtung // verlangt, dass sie providerneutral bleibt. var ErrArchiveTransportMissing = proxmox.ErrArchiveTransportMissing