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>
244 lines
8.7 KiB
Go
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
|