// Package hypervisor verwaltet eingerichtete Virtualisierungsumgebungen. // // Es ist die Naht zwischen der Control Plane und dem herstellerneutralen // Provider-Interface: Hier liegen Zugangsdaten, Bestand und der Weg zu den // Sicherungsarchiven; der Provider selbst kennt weder Datenbank noch // Verschlüsselung. // // Bewusst ein eigenes Paket und nicht Teil von providers/proxmox: Ein // VMware- oder Hyper-V-Provider bekäme sonst entweder eine zweite // Datenzugriffsschicht oder eine Abhängigkeit auf Proxmox. Die Tabellen heißen // aus historischen Gründen `proxmox_clusters` (so steht es in // SYNCOVA_DATABASE.md §4); das Modell hier ist es nicht. package hypervisor import ( "errors" "fmt" "net/url" "strings" "time" "github.com/google/uuid" ) // TransportKind benennt den Weg zu den Sicherungsarchiven. type TransportKind string const ( // TransportLocal liest die Archive über das Dateisystem. // // Gilt, wenn Syncova auf dem Knoten läuft oder der Sicherungsspeicher auf // dem Syncova-Server eingehängt ist. TransportLocal TransportKind = "local" // TransportSSH liest die Archive über eine SSH-Verbindung zum Knoten. TransportSSH TransportKind = "ssh" ) // ClusterStatus ist das Ergebnis der letzten Verbindungsprüfung. type ClusterStatus string const ( // StatusUnknown bedeutet: noch nie geprüft. // // Ausdrücklich von „erreichbar" getrennt. Ein Verbund, der als erreichbar // gilt, weil ihn niemand geprüft hat, ist die bequeme und falsche Auskunft. StatusUnknown ClusterStatus = "unknown" // StatusReachable bedeutet: Anmeldung und Abruf haben funktioniert. StatusReachable ClusterStatus = "reachable" // StatusUnreachable bedeutet: der Verbund antwortet nicht. StatusUnreachable ClusterStatus = "unreachable" // StatusUnauthorized bedeutet: er antwortet, lehnt aber die Anmeldung ab. // // Getrennt von „nicht erreichbar", weil die Abhilfe eine völlig andere ist: // Das eine ist ein Netzproblem, das andere ein abgelaufenes oder // entzogenes Token. StatusUnauthorized ClusterStatus = "unauthorized" ) // Cluster ist eine eingerichtete Virtualisierungsumgebung. type Cluster struct { // ID ist die öffentliche Kennung. ID uuid.UUID `json:"id"` // Name ist die sprechende Bezeichnung. Name string `json:"name"` // APIEndpoint ist die Basisadresse der Proxmox-API. APIEndpoint string `json:"api_endpoint"` // APITokenID ist die Kennung des API-Tokens. // // Kein Geheimnis: Sie steht in jeder Proxmox-Oberfläche und wird zur // Fehlersuche gebraucht. Der Wert daneben ist das Geheimnis und erscheint // niemals in einer Antwort. APITokenID string `json:"api_token_id"` // TLSFingerprint bindet ein selbstsigniertes Zertifikat. TLSFingerprint string `json:"tls_fingerprint,omitempty"` // BackupStorageID ist der Speicher für die vzdump-Archive. BackupStorageID string `json:"backup_storage_id"` // ArchiveTransport ist der Weg zu den Archivdateien. ArchiveTransport TransportKind `json:"archive_transport"` // ArchiveMountRoots ordnet Speicherkennungen lokalen Pfaden zu. ArchiveMountRoots map[string]string `json:"archive_mount_roots,omitempty"` // SSHUsername ist das Anmeldekonto auf den Knoten. SSHUsername string `json:"ssh_username,omitempty"` // SSHPort ist der Port; 0 bedeutet 22. SSHPort int `json:"ssh_port,omitempty"` // SSHHostFingerprints sind die Wirtsschlüssel je Knoten. SSHHostFingerprints map[string]string `json:"ssh_host_fingerprints,omitempty"` // KeepArchiveOnNode lässt das vzdump-Archiv nach der Übernahme liegen. KeepArchiveOnNode bool `json:"keep_archive_on_node"` // Status ist das Ergebnis der letzten Prüfung. Status ClusterStatus `json:"status"` // LastError ist die Meldung der letzten fehlgeschlagenen Prüfung. LastError string `json:"last_error,omitempty"` // LastSeenAt ist der Zeitpunkt der letzten erfolgreichen Verbindung. LastSeenAt *time.Time `json:"last_seen_at,omitempty"` // LastDiscoveryAt ist der Zeitpunkt der letzten Bestandsaufnahme. LastDiscoveryAt *time.Time `json:"last_discovery_at,omitempty"` // CreatedAt ist der Zeitpunkt der Einrichtung. CreatedAt time.Time `json:"created_at"` // UpdatedAt ist der Zeitpunkt der letzten Änderung. UpdatedAt time.Time `json:"updated_at"` } // ClusterCredentials sind die entschlüsselten Geheimnisse eines Verbunds. // // Getrennt vom Cluster, damit sie nicht versehentlich in eine API-Antwort // geraten: Ein Feld, das in derselben Struktur liegt, wird irgendwann // mitserialisiert (PROMPT.md §140). type ClusterCredentials struct { // APITokenSecret ist der Wert des API-Tokens. APITokenSecret string // SSHPrivateKeyPEM ist der private Schlüssel für den SSH-Weg. SSHPrivateKeyPEM []byte } // ClusterInput sind die Angaben zum Einrichten oder Ändern eines Verbunds. type ClusterInput struct { // Name ist die sprechende Bezeichnung. Name string `json:"name"` // APIEndpoint ist die Basisadresse der API. APIEndpoint string `json:"api_endpoint"` // APITokenID ist die Kennung des API-Tokens. APITokenID string `json:"api_token_id"` // APITokenSecret ist der Wert des API-Tokens. APITokenSecret string `json:"api_token_secret"` // TLSFingerprint bindet ein selbstsigniertes Zertifikat. TLSFingerprint string `json:"tls_fingerprint,omitempty"` // BackupStorageID ist der Speicher für die vzdump-Archive. BackupStorageID string `json:"backup_storage_id"` // ArchiveTransport ist der Weg zu den Archivdateien. ArchiveTransport TransportKind `json:"archive_transport"` // ArchiveMountRoots ordnet Speicherkennungen lokalen Pfaden zu. ArchiveMountRoots map[string]string `json:"archive_mount_roots,omitempty"` // SSHUsername ist das Anmeldekonto auf den Knoten. SSHUsername string `json:"ssh_username,omitempty"` // SSHPort ist der Port; 0 bedeutet 22. SSHPort int `json:"ssh_port,omitempty"` // SSHPrivateKeyPEM ist der private Schlüssel im PEM-Format. SSHPrivateKeyPEM string `json:"ssh_private_key_pem,omitempty"` // SSHHostFingerprints sind die Wirtsschlüssel je Knoten. SSHHostFingerprints map[string]string `json:"ssh_host_fingerprints,omitempty"` // KeepArchiveOnNode lässt das vzdump-Archiv nach der Übernahme liegen. KeepArchiveOnNode bool `json:"keep_archive_on_node,omitempty"` } // Validate prüft die Angaben, bevor etwas gespeichert wird. // // Die Prüfung ist streng, weil der Fehler sonst erst um zwei Uhr nachts // auffällt: Ein Verbund ohne Zugriffsweg auf die Archive lässt sich einrichten, // meldet „erreichbar" und liefert bei der ersten Sicherung kein einziges Byte. func (input ClusterInput) Validate() error { if strings.TrimSpace(input.Name) == "" { return errors.New("der verbund braucht einen namen") } parsedEndpoint, parseError := url.Parse(strings.TrimSpace(input.APIEndpoint)) if parseError != nil || parsedEndpoint.Host == "" { return fmt.Errorf("die api-adresse %q ist keine gueltige url", input.APIEndpoint) } // Proxmox spricht ausschließlich HTTPS. Ein http:// hier wäre ein // Tippfehler mit der Folge, dass das API-Token im Klartext über das Netz // ginge. if parsedEndpoint.Scheme != "https" { return errors.New("die api-adresse muss mit https:// beginnen; das api-token ginge sonst im klartext ueber das netz") } if strings.TrimSpace(input.APITokenID) == "" || strings.TrimSpace(input.APITokenSecret) == "" { return errors.New("der verbund braucht kennung und wert eines api-tokens") } if strings.TrimSpace(input.BackupStorageID) == "" { return errors.New("es muss ein speicher fuer die sicherungsarchive angegeben werden") } switch input.ArchiveTransport { case TransportLocal: if len(input.ArchiveMountRoots) == 0 { return errors.New("der lokale zugriffsweg braucht mindestens eine zuordnung " + "von proxmox-speicher zu lokalem pfad") } for storageIdentifier, mountPath := range input.ArchiveMountRoots { if strings.TrimSpace(storageIdentifier) == "" || !strings.HasPrefix(mountPath, "/") { return fmt.Errorf("die zuordnung %q -> %q ist unvollstaendig; der pfad muss absolut sein", storageIdentifier, mountPath) } } case TransportSSH: if strings.TrimSpace(input.SSHUsername) == "" { return errors.New("der ssh-zugriffsweg braucht ein anmeldekonto") } if strings.TrimSpace(input.SSHPrivateKeyPEM) == "" { return errors.New("der ssh-zugriffsweg braucht einen privaten schluessel") } // Ohne hinterlegte Wirtsschlüssel liesse sich ein Zwischenangriff nicht // erkennen — der Angreifer lieferte dann das Archiv, das Syncova für // ein Backup hält. if len(input.SSHHostFingerprints) == 0 { return errors.New("es muss mindestens ein fingerabdruck eines wirtsschluessels " + "hinterlegt werden; ohne ihn liesse sich ein zwischenangriff nicht erkennen") } if input.SSHPort < 0 || input.SSHPort > 65535 { return fmt.Errorf("der ssh-port %d liegt ausserhalb des gueltigen bereichs", input.SSHPort) } default: return fmt.Errorf("der zugriffsweg %q ist unbekannt; zulaessig sind 'local' und 'ssh'", input.ArchiveTransport) } return nil } // Host ist ein Knoten eines Verbunds. type Host struct { // ID ist die öffentliche Kennung. ID uuid.UUID `json:"id"` // ClusterID ist der Verbund. ClusterID uuid.UUID `json:"cluster_id"` // NodeName ist der Name des Knotens bei Proxmox. NodeName string `json:"node_name"` // Status ist der zuletzt gemeldete Zustand. Status string `json:"status"` // CPUCount ist die Zahl logischer Prozessoren. CPUCount int `json:"cpu_count,omitempty"` // MemoryBytes ist der Gesamtarbeitsspeicher. MemoryBytes int64 `json:"memory_bytes,omitempty"` // LastSeenAt ist der Zeitpunkt der letzten Aufnahme. LastSeenAt *time.Time `json:"last_seen_at,omitempty"` } // VirtualMachine ist ein aufgenommener Gast. type VirtualMachine struct { // ID ist die öffentliche Kennung. ID uuid.UUID `json:"id"` // ClusterID ist der Verbund. ClusterID uuid.UUID `json:"cluster_id"` // HostID ist der Knoten, sofern bekannt. HostID *uuid.UUID `json:"host_id,omitempty"` // ProviderVMID ist die Kennung beim Provider, etwa "qemu/100". ProviderVMID string `json:"provider_vm_id"` // Name ist die sprechende Bezeichnung. Name string `json:"name"` // GuestKind unterscheidet virtuelle Maschine und Container. GuestKind string `json:"guest_kind"` // Status ist der Betriebszustand. Status string `json:"status,omitempty"` // CPUCount ist die Zahl zugewiesener Prozessoren. CPUCount int `json:"cpu_count,omitempty"` // MemoryBytes ist der zugewiesene Arbeitsspeicher. MemoryBytes int64 `json:"memory_bytes,omitempty"` // NodeName ist der Knoten, auf dem der Gast liegt. NodeName string `json:"node_name,omitempty"` // DiskCount ist die Zahl gesicherter Platten. DiskCount int `json:"disk_count"` // ExcludedDiskCount ist die Zahl von der Sicherung ausgenommener Platten. // // Ausdrücklich ausgewiesen: Eine Wiederherstellung liefert dann eine // unvollständige Maschine, und wer es nicht weiß, hält sie für vollständig. ExcludedDiskCount int `json:"excluded_disk_count"` // GuestAgentRunning meldet, ob der Gastdienst antwortet. // // Ein Zeiger, weil „nicht geprüft" etwas anderes ist als „läuft nicht". GuestAgentRunning *bool `json:"guest_agent_running,omitempty"` // LastDiscoveredAt ist der Zeitpunkt der letzten Aufnahme. LastDiscoveredAt time.Time `json:"last_discovered_at"` // MissingSince ist gesetzt, wenn der Gast bei der letzten Aufnahme fehlte. MissingSince *time.Time `json:"missing_since,omitempty"` } // DiscoveryResult fasst eine Bestandsaufnahme zusammen. type DiscoveryResult struct { // ClusterID ist der aufgenommene Verbund. ClusterID uuid.UUID `json:"cluster_id"` // HostsFound ist die Zahl gefundener Knoten. HostsFound int `json:"hosts_found"` // GuestsFound ist die Zahl gefundener Gäste. GuestsFound int `json:"guests_found"` // GuestsMissing ist die Zahl zuvor bekannter, jetzt fehlender Gäste. // // Sie werden nicht gelöscht: Ein Gast könnte abgeschaltet, verschoben oder // gelöscht worden sein, und gelöschte Zeilen nähmen die Zuordnung zu // vorhandenen Backups mit. GuestsMissing int `json:"guests_missing"` // Warnings sind Hinweise, die den Lauf nicht verhindert haben. Warnings []string `json:"warnings,omitempty"` // CompletedAt ist der Abschlusszeitpunkt. CompletedAt time.Time `json:"completed_at"` }