Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
534 lines
17 KiB
Go
534 lines
17 KiB
Go
package hypervisor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
)
|
|
|
|
// ErrClusterNotFound meldet einen unbekannten Verbund.
|
|
var ErrClusterNotFound = errors.New("der virtualisierungsverbund wurde nicht gefunden")
|
|
|
|
// ErrClusterInUse meldet einen Verbund, auf den noch Sicherungsquellen verweisen.
|
|
//
|
|
// Ihn zu löschen nähme den betroffenen Aufträgen ihre Grundlage — und zwar
|
|
// stillschweigend, bis der nächste Lauf scheitert.
|
|
var ErrClusterInUse = errors.New("auf diesen verbund verweisen noch sicherungsquellen")
|
|
|
|
// Store ist die Datenzugriffsschicht der Virtualisierungsumgebungen.
|
|
type Store struct {
|
|
// connectionPool ist der Datenbankpool der Control Plane.
|
|
connectionPool *pgxpool.Pool
|
|
// secretStore ver- und entschlüsselt Zugangsdaten.
|
|
//
|
|
// Ohne ihn lässt sich kein Verbund einrichten: Ein API-Token im Klartext in
|
|
// der Datenbank wäre ein Verstoß gegen PROMPT.md §140 — und wer die
|
|
// Datenbank sichert, sicherte die Zugangsdaten gleich mit.
|
|
secretStore crypto.SecretStore
|
|
}
|
|
|
|
// NewStore erzeugt die Datenzugriffsschicht.
|
|
func NewStore(connectionPool *pgxpool.Pool, secretStore crypto.SecretStore) (*Store, error) {
|
|
if connectionPool == nil {
|
|
return nil, errors.New("der speicher braucht einen datenbankpool")
|
|
}
|
|
|
|
if secretStore == nil {
|
|
return nil, errors.New("ohne schluesselmaterial lassen sich keine zugangsdaten ablegen; " +
|
|
"setzen sie SYNCOVA_ENCRYPTION_KEYS")
|
|
}
|
|
|
|
return &Store{connectionPool: connectionPool, secretStore: secretStore}, nil
|
|
}
|
|
|
|
// clusterColumns ist die gemeinsame Spaltenliste aller Leseabfragen.
|
|
//
|
|
// An einer Stelle, weil zwei auseinanderlaufende Listen genau dann auffallen,
|
|
// wenn ein Feld fehlt und niemand weiß, warum.
|
|
const clusterColumns = `
|
|
id, name, api_endpoint, api_token_id, tls_fingerprint, backup_storage_id,
|
|
archive_transport, archive_mount_roots, ssh_username, ssh_port,
|
|
ssh_host_fingerprints, keep_archive_on_node, status, last_error,
|
|
last_seen_at, last_discovery_at, created_at, updated_at`
|
|
|
|
// CreateCluster richtet einen Verbund ein.
|
|
func (store *Store) CreateCluster(createContext context.Context, input ClusterInput) (*Cluster, error) {
|
|
if validationError := input.Validate(); validationError != nil {
|
|
return nil, validationError
|
|
}
|
|
|
|
tokenCiphertext, tokenKeyVersion, encryptError := store.secretStore.Encrypt([]byte(input.APITokenSecret))
|
|
if encryptError != nil {
|
|
return nil, fmt.Errorf("das api-token liess sich nicht verschluesseln: %w", encryptError)
|
|
}
|
|
|
|
var (
|
|
keyCiphertext []byte
|
|
keyVersion *string
|
|
)
|
|
|
|
if input.SSHPrivateKeyPEM != "" {
|
|
encryptedKey, version, keyEncryptError := store.secretStore.Encrypt([]byte(input.SSHPrivateKeyPEM))
|
|
if keyEncryptError != nil {
|
|
return nil, fmt.Errorf("der ssh-schluessel liess sich nicht verschluesseln: %w", keyEncryptError)
|
|
}
|
|
|
|
keyCiphertext = encryptedKey
|
|
keyVersion = &version
|
|
}
|
|
|
|
mountRootsJSON, hostFingerprintsJSON, encodeError := encodeMaps(input.ArchiveMountRoots, input.SSHHostFingerprints)
|
|
if encodeError != nil {
|
|
return nil, encodeError
|
|
}
|
|
|
|
const insertStatement = `
|
|
INSERT INTO proxmox_clusters (
|
|
name, api_endpoint, api_token_id, api_token_ciphertext, api_token_key_version,
|
|
tls_fingerprint, backup_storage_id, archive_transport, archive_mount_roots,
|
|
ssh_username, ssh_port, ssh_private_key_ciphertext, ssh_private_key_version,
|
|
ssh_host_fingerprints, keep_archive_on_node)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING ` + clusterColumns
|
|
|
|
createdCluster, scanError := scanCluster(store.connectionPool.QueryRow(createContext, insertStatement,
|
|
input.Name, input.APIEndpoint, input.APITokenID, tokenCiphertext, tokenKeyVersion,
|
|
emptyToNil(input.TLSFingerprint), input.BackupStorageID, string(input.ArchiveTransport), mountRootsJSON,
|
|
emptyToNil(input.SSHUsername), zeroToNil(input.SSHPort), keyCiphertext, keyVersion,
|
|
hostFingerprintsJSON, input.KeepArchiveOnNode))
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("der verbund liess sich nicht anlegen: %w", scanError)
|
|
}
|
|
|
|
return createdCluster, nil
|
|
}
|
|
|
|
// ListClusters liefert alle eingerichteten Verbünde.
|
|
func (store *Store) ListClusters(listContext context.Context) ([]Cluster, error) {
|
|
const selectStatement = `SELECT ` + clusterColumns + ` FROM proxmox_clusters ORDER BY name`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(listContext, selectStatement)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die verbuende liessen sich nicht lesen: %w", queryError)
|
|
}
|
|
defer resultRows.Close()
|
|
|
|
clusterList := make([]Cluster, 0, 4)
|
|
|
|
for resultRows.Next() {
|
|
scannedCluster, scanError := scanCluster(resultRows)
|
|
if scanError != nil {
|
|
return nil, scanError
|
|
}
|
|
|
|
clusterList = append(clusterList, *scannedCluster)
|
|
}
|
|
|
|
return clusterList, resultRows.Err()
|
|
}
|
|
|
|
// GetCluster liefert einen einzelnen Verbund.
|
|
func (store *Store) GetCluster(readContext context.Context, clusterIdentifier uuid.UUID) (*Cluster, error) {
|
|
const selectStatement = `SELECT ` + clusterColumns + ` FROM proxmox_clusters WHERE id = $1`
|
|
|
|
foundCluster, scanError := scanCluster(store.connectionPool.QueryRow(readContext, selectStatement, clusterIdentifier))
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return nil, ErrClusterNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("der verbund liess sich nicht lesen: %w", scanError)
|
|
}
|
|
|
|
return foundCluster, nil
|
|
}
|
|
|
|
// LoadCredentials entschlüsselt die Zugangsdaten eines Verbunds.
|
|
//
|
|
// Ein eigener Aufruf und nicht Teil von GetCluster: Wer einen Verbund nur
|
|
// anzeigt, soll die Geheimnisse gar nicht erst im Speicher haben.
|
|
func (store *Store) LoadCredentials(readContext context.Context,
|
|
clusterIdentifier uuid.UUID) (*ClusterCredentials, error) {
|
|
const selectStatement = `
|
|
SELECT api_token_ciphertext, api_token_key_version,
|
|
ssh_private_key_ciphertext, ssh_private_key_version
|
|
FROM proxmox_clusters WHERE id = $1`
|
|
|
|
var (
|
|
tokenCiphertext []byte
|
|
tokenVersion string
|
|
keyCiphertext []byte
|
|
keyVersion *string
|
|
)
|
|
|
|
scanError := store.connectionPool.QueryRow(readContext, selectStatement, clusterIdentifier).Scan(
|
|
&tokenCiphertext, &tokenVersion, &keyCiphertext, &keyVersion)
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return nil, ErrClusterNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("die zugangsdaten liessen sich nicht lesen: %w", scanError)
|
|
}
|
|
|
|
tokenSecret, decryptError := store.secretStore.Decrypt(tokenCiphertext, tokenVersion)
|
|
if decryptError != nil {
|
|
return nil, fmt.Errorf("das api-token liess sich nicht entschluesseln: %w", decryptError)
|
|
}
|
|
|
|
credentials := &ClusterCredentials{APITokenSecret: string(tokenSecret)}
|
|
|
|
if len(keyCiphertext) > 0 && keyVersion != nil {
|
|
privateKey, keyDecryptError := store.secretStore.Decrypt(keyCiphertext, *keyVersion)
|
|
if keyDecryptError != nil {
|
|
return nil, fmt.Errorf("der ssh-schluessel liess sich nicht entschluesseln: %w", keyDecryptError)
|
|
}
|
|
|
|
credentials.SSHPrivateKeyPEM = privateKey
|
|
}
|
|
|
|
return credentials, nil
|
|
}
|
|
|
|
// RecordConnectionResult vermerkt das Ergebnis einer Verbindungsprüfung.
|
|
func (store *Store) RecordConnectionResult(recordContext context.Context, clusterIdentifier uuid.UUID,
|
|
status ClusterStatus, connectionError error) error {
|
|
const updateStatement = `
|
|
UPDATE proxmox_clusters
|
|
SET status = $2,
|
|
last_error = $3,
|
|
-- Der Zeitpunkt wird nur bei Erfolg fortgeschrieben. Ihn bei jedem
|
|
-- Versuch zu setzen machte aus "zuletzt erreicht" ein "zuletzt
|
|
-- versucht" — und ein seit Wochen toter Verbund sähe frisch aus.
|
|
last_seen_at = CASE WHEN $2 = 'reachable' THEN NOW() ELSE last_seen_at END,
|
|
updated_at = NOW()
|
|
WHERE id = $1`
|
|
|
|
var errorMessage *string
|
|
|
|
if connectionError != nil {
|
|
message := connectionError.Error()
|
|
errorMessage = &message
|
|
}
|
|
|
|
_, executeError := store.connectionPool.Exec(recordContext, updateStatement,
|
|
clusterIdentifier, string(status), errorMessage)
|
|
if executeError != nil {
|
|
return fmt.Errorf("das pruefergebnis liess sich nicht vermerken: %w", executeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteCluster entfernt einen Verbund.
|
|
func (store *Store) DeleteCluster(deleteContext context.Context, clusterIdentifier uuid.UUID) error {
|
|
// Erst die Verwendung prüfen: Der Fremdschlüssel steht auf RESTRICT, aber
|
|
// eine Fremdschlüsselverletzung ist für den Aufrufer keine Auskunft.
|
|
const usageStatement = `SELECT COUNT(*) FROM backup_job_sources WHERE cluster_id = $1`
|
|
|
|
var sourceCount int
|
|
|
|
if countError := store.connectionPool.QueryRow(deleteContext, usageStatement,
|
|
clusterIdentifier).Scan(&sourceCount); countError != nil {
|
|
return fmt.Errorf("die verwendung liess sich nicht pruefen: %w", countError)
|
|
}
|
|
|
|
if sourceCount > 0 {
|
|
return fmt.Errorf("%w (%d quelle(n))", ErrClusterInUse, sourceCount)
|
|
}
|
|
|
|
commandTag, deleteError := store.connectionPool.Exec(deleteContext,
|
|
`DELETE FROM proxmox_clusters WHERE id = $1`, clusterIdentifier)
|
|
if deleteError != nil {
|
|
return fmt.Errorf("der verbund liess sich nicht entfernen: %w", deleteError)
|
|
}
|
|
|
|
if commandTag.RowsAffected() == 0 {
|
|
return ErrClusterNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListVirtualMachines liefert den aufgenommenen Gästebestand.
|
|
func (store *Store) ListVirtualMachines(listContext context.Context,
|
|
clusterIdentifier *uuid.UUID) ([]VirtualMachine, error) {
|
|
const selectStatement = `
|
|
SELECT vm.id, vm.cluster_id, vm.host_id, vm.provider_vm_id, vm.name, vm.guest_kind,
|
|
vm.status, vm.cpu_count, vm.memory_bytes, host.node_name,
|
|
vm.disks_json, vm.guest_agent_running, vm.last_discovered_at, vm.missing_since
|
|
FROM virtual_machines vm
|
|
LEFT JOIN proxmox_hosts host ON host.id = vm.host_id
|
|
WHERE ($1::uuid IS NULL OR vm.cluster_id = $1)
|
|
ORDER BY vm.name`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(listContext, selectStatement, clusterIdentifier)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("der gaestebestand liess sich nicht lesen: %w", queryError)
|
|
}
|
|
defer resultRows.Close()
|
|
|
|
machineList := make([]VirtualMachine, 0, 16)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
scannedMachine VirtualMachine
|
|
nodeName *string
|
|
cpuCount *int
|
|
memoryBytes *int64
|
|
diskDefinitions []byte
|
|
guestStatus *string
|
|
)
|
|
|
|
scanError := resultRows.Scan(&scannedMachine.ID, &scannedMachine.ClusterID, &scannedMachine.HostID,
|
|
&scannedMachine.ProviderVMID, &scannedMachine.Name, &scannedMachine.GuestKind,
|
|
&guestStatus, &cpuCount, &memoryBytes, &nodeName, &diskDefinitions,
|
|
&scannedMachine.GuestAgentRunning, &scannedMachine.LastDiscoveredAt, &scannedMachine.MissingSince)
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("ein gast liess sich nicht lesen: %w", scanError)
|
|
}
|
|
|
|
scannedMachine.Status = stringValue(guestStatus)
|
|
scannedMachine.NodeName = stringValue(nodeName)
|
|
|
|
if cpuCount != nil {
|
|
scannedMachine.CPUCount = *cpuCount
|
|
}
|
|
|
|
if memoryBytes != nil {
|
|
scannedMachine.MemoryBytes = *memoryBytes
|
|
}
|
|
|
|
scannedMachine.DiskCount, scannedMachine.ExcludedDiskCount = countDisks(diskDefinitions)
|
|
|
|
machineList = append(machineList, scannedMachine)
|
|
}
|
|
|
|
return machineList, resultRows.Err()
|
|
}
|
|
|
|
// FindVirtualMachine sucht einen Gast anhand seiner Providerkennung.
|
|
func (store *Store) FindVirtualMachine(readContext context.Context, clusterIdentifier uuid.UUID,
|
|
providerVMID string) (*VirtualMachine, error) {
|
|
machineList, listError := store.ListVirtualMachines(readContext, &clusterIdentifier)
|
|
if listError != nil {
|
|
return nil, listError
|
|
}
|
|
|
|
for _, candidateMachine := range machineList {
|
|
if candidateMachine.ProviderVMID == providerVMID {
|
|
return &candidateMachine, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("der gast %q ist im bestand des verbunds nicht enthalten", providerVMID)
|
|
}
|
|
|
|
// countDisks zählt gesicherte und ausgenommene Platten.
|
|
//
|
|
// Eine beschädigte Plattenliste wird als „keine Angabe" gewertet und nicht als
|
|
// „keine Platten": Der Unterschied entscheidet darüber, ob jemand eine
|
|
// unvollständige Maschine für vollständig hält.
|
|
func countDisks(diskDefinitions []byte) (diskCount int, excludedCount int) {
|
|
if len(diskDefinitions) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
var decodedDisks []struct {
|
|
ExcludedFromBackup bool `json:"excluded_from_backup"`
|
|
}
|
|
|
|
if decodeError := json.Unmarshal(diskDefinitions, &decodedDisks); decodeError != nil {
|
|
return 0, 0
|
|
}
|
|
|
|
for _, singleDisk := range decodedDisks {
|
|
if singleDisk.ExcludedFromBackup {
|
|
excludedCount++
|
|
|
|
continue
|
|
}
|
|
|
|
diskCount++
|
|
}
|
|
|
|
return diskCount, excludedCount
|
|
}
|
|
|
|
// rowScanner deckt QueryRow und Rows gleichermaßen ab.
|
|
type rowScanner interface {
|
|
// Scan liest eine Zeile.
|
|
Scan(destinations ...any) error
|
|
}
|
|
|
|
// scanCluster liest eine Verbundzeile.
|
|
func scanCluster(scanner rowScanner) (*Cluster, error) {
|
|
var (
|
|
scannedCluster Cluster
|
|
transportKind string
|
|
mountRootsJSON []byte
|
|
hostFingerprintsJSON []byte
|
|
tlsFingerprint *string
|
|
sshUsername *string
|
|
sshPort *int
|
|
lastError *string
|
|
statusValue string
|
|
)
|
|
|
|
scanError := scanner.Scan(&scannedCluster.ID, &scannedCluster.Name, &scannedCluster.APIEndpoint,
|
|
&scannedCluster.APITokenID, &tlsFingerprint, &scannedCluster.BackupStorageID,
|
|
&transportKind, &mountRootsJSON, &sshUsername, &sshPort, &hostFingerprintsJSON,
|
|
&scannedCluster.KeepArchiveOnNode, &statusValue, &lastError,
|
|
&scannedCluster.LastSeenAt, &scannedCluster.LastDiscoveryAt,
|
|
&scannedCluster.CreatedAt, &scannedCluster.UpdatedAt)
|
|
if scanError != nil {
|
|
return nil, scanError
|
|
}
|
|
|
|
scannedCluster.ArchiveTransport = TransportKind(transportKind)
|
|
scannedCluster.Status = ClusterStatus(statusValue)
|
|
scannedCluster.TLSFingerprint = stringValue(tlsFingerprint)
|
|
scannedCluster.SSHUsername = stringValue(sshUsername)
|
|
scannedCluster.LastError = stringValue(lastError)
|
|
|
|
if sshPort != nil {
|
|
scannedCluster.SSHPort = *sshPort
|
|
}
|
|
|
|
if decodeError := json.Unmarshal(mountRootsJSON, &scannedCluster.ArchiveMountRoots); decodeError != nil {
|
|
return nil, fmt.Errorf("die speicherzuordnung liess sich nicht lesen: %w", decodeError)
|
|
}
|
|
|
|
if decodeError := json.Unmarshal(hostFingerprintsJSON, &scannedCluster.SSHHostFingerprints); decodeError != nil {
|
|
return nil, fmt.Errorf("die wirtsschluessel liessen sich nicht lesen: %w", decodeError)
|
|
}
|
|
|
|
return &scannedCluster, nil
|
|
}
|
|
|
|
// encodeMaps kodiert die beiden Zuordnungen für JSONB-Spalten.
|
|
func encodeMaps(mountRoots map[string]string, hostFingerprints map[string]string) ([]byte, []byte, error) {
|
|
if mountRoots == nil {
|
|
mountRoots = map[string]string{}
|
|
}
|
|
|
|
if hostFingerprints == nil {
|
|
hostFingerprints = map[string]string{}
|
|
}
|
|
|
|
encodedMountRoots, mountError := json.Marshal(mountRoots)
|
|
if mountError != nil {
|
|
return nil, nil, fmt.Errorf("die speicherzuordnung liess sich nicht kodieren: %w", mountError)
|
|
}
|
|
|
|
encodedFingerprints, fingerprintError := json.Marshal(hostFingerprints)
|
|
if fingerprintError != nil {
|
|
return nil, nil, fmt.Errorf("die wirtsschluessel liessen sich nicht kodieren: %w", fingerprintError)
|
|
}
|
|
|
|
return encodedMountRoots, encodedFingerprints, nil
|
|
}
|
|
|
|
// emptyToNil macht aus einer leeren Zeichenkette ein NULL.
|
|
//
|
|
// Nötig, weil eine leere Zeichenkette und „nicht gesetzt" in der Datenbank
|
|
// verschiedene Dinge sind — ein leerer Fingerabdruck sähe sonst aus wie ein
|
|
// hinterlegter.
|
|
func emptyToNil(rawValue string) *string {
|
|
if rawValue == "" {
|
|
return nil
|
|
}
|
|
|
|
return &rawValue
|
|
}
|
|
|
|
// zeroToNil macht aus einer Null ein NULL.
|
|
func zeroToNil(rawValue int) *int {
|
|
if rawValue == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &rawValue
|
|
}
|
|
|
|
// stringValue liest einen Zeiger auf eine Zeichenkette.
|
|
func stringValue(rawValue *string) string {
|
|
if rawValue == nil {
|
|
return ""
|
|
}
|
|
|
|
return *rawValue
|
|
}
|
|
|
|
// ListHosts liefert die Knoten eines Verbunds.
|
|
func (store *Store) ListHosts(listContext context.Context, clusterIdentifier uuid.UUID) ([]Host, error) {
|
|
const selectStatement = `
|
|
SELECT id, cluster_id, node_name, status, cpu_count, memory_bytes, last_seen_at
|
|
FROM proxmox_hosts
|
|
WHERE cluster_id = $1
|
|
ORDER BY node_name`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(listContext, selectStatement, clusterIdentifier)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die knoten liessen sich nicht lesen: %w", queryError)
|
|
}
|
|
defer resultRows.Close()
|
|
|
|
hostList := make([]Host, 0, 4)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
scannedHost Host
|
|
cpuCount *int
|
|
memoryBytes *int64
|
|
)
|
|
|
|
if scanError := resultRows.Scan(&scannedHost.ID, &scannedHost.ClusterID, &scannedHost.NodeName,
|
|
&scannedHost.Status, &cpuCount, &memoryBytes, &scannedHost.LastSeenAt); scanError != nil {
|
|
return nil, fmt.Errorf("ein knoten liess sich nicht lesen: %w", scanError)
|
|
}
|
|
|
|
if cpuCount != nil {
|
|
scannedHost.CPUCount = *cpuCount
|
|
}
|
|
|
|
if memoryBytes != nil {
|
|
scannedHost.MemoryBytes = *memoryBytes
|
|
}
|
|
|
|
hostList = append(hostList, scannedHost)
|
|
}
|
|
|
|
return hostList, resultRows.Err()
|
|
}
|
|
|
|
// ErrVirtualMachineNotFound meldet einen unbekannten Gast.
|
|
var ErrVirtualMachineNotFound = errors.New("der gast wurde im bestand nicht gefunden")
|
|
|
|
// GetVirtualMachine liefert einen einzelnen Gast anhand seiner Kennung.
|
|
func (store *Store) GetVirtualMachine(readContext context.Context,
|
|
machineIdentifier uuid.UUID) (*VirtualMachine, error) {
|
|
// Über die Listenabfrage, damit die Auswertung der Plattenliste an einer
|
|
// Stelle bleibt: Zwei Auswertungen derselben Spalte laufen auseinander, und
|
|
// man merkt es erst, wenn jemand nachrechnet.
|
|
machineList, listError := store.ListVirtualMachines(readContext, nil)
|
|
if listError != nil {
|
|
return nil, listError
|
|
}
|
|
|
|
for _, candidateMachine := range machineList {
|
|
if candidateMachine.ID == machineIdentifier {
|
|
return &candidateMachine, nil
|
|
}
|
|
}
|
|
|
|
return nil, ErrVirtualMachineNotFound
|
|
}
|