syncova-backup/packages/hypervisor/factory.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

244 lines
8.7 KiB
Go

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