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>
170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
package hypervisor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/syncova/syncova/packages/providers"
|
|
)
|
|
|
|
// saveHosts schreibt die Knoten fort und liefert ihre Kennungen.
|
|
//
|
|
// Der Rückgabewert ordnet die Providerkennung des Wirts der Datenbankkennung
|
|
// zu; ohne sie ließe sich ein Gast nicht an seinen Knoten binden.
|
|
func (store *Store) saveHosts(saveContext context.Context, clusterIdentifier uuid.UUID,
|
|
foundHosts []providers.Host) (map[string]uuid.UUID, error) {
|
|
const upsertStatement = `
|
|
INSERT INTO proxmox_hosts (cluster_id, node_name, status, cpu_count, memory_bytes, last_seen_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (cluster_id, node_name) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
cpu_count = EXCLUDED.cpu_count,
|
|
memory_bytes = EXCLUDED.memory_bytes,
|
|
last_seen_at = NOW(),
|
|
updated_at = NOW()
|
|
RETURNING id`
|
|
|
|
hostIdentifiers := make(map[string]uuid.UUID, len(foundHosts))
|
|
|
|
for _, foundHost := range foundHosts {
|
|
hostStatus := "offline"
|
|
if foundHost.Online {
|
|
hostStatus = "online"
|
|
}
|
|
|
|
var storedIdentifier uuid.UUID
|
|
|
|
scanError := store.connectionPool.QueryRow(saveContext, upsertStatement,
|
|
clusterIdentifier, foundHost.Name, hostStatus,
|
|
zeroToNil(foundHost.CPUCount), zeroToNilInt64(foundHost.MemoryBytes)).Scan(&storedIdentifier)
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("der knoten %q liess sich nicht speichern: %w", foundHost.Name, scanError)
|
|
}
|
|
|
|
// Beide Schreibweisen eintragen: Ein Gast nennt als Wirt mal die
|
|
// Kennung, mal den Knotennamen — je nachdem, woher er stammt.
|
|
hostIdentifiers[foundHost.Identifier] = storedIdentifier
|
|
hostIdentifiers[foundHost.Name] = storedIdentifier
|
|
}
|
|
|
|
return hostIdentifiers, nil
|
|
}
|
|
|
|
// saveGuest schreibt einen Gast fort.
|
|
func (store *Store) saveGuest(saveContext context.Context, clusterIdentifier uuid.UUID,
|
|
hostIdentifier uuid.UUID, foundGuest providers.Guest, guestDisks []providers.Disk,
|
|
guestMetadata *providers.GuestMetadata) error {
|
|
encodedDisks, encodeError := json.Marshal(guestDisks)
|
|
if encodeError != nil {
|
|
return fmt.Errorf("die plattenliste von %q liess sich nicht kodieren: %w", foundGuest.Name, encodeError)
|
|
}
|
|
|
|
var (
|
|
encodedConfiguration []byte
|
|
guestAgentRunning *bool
|
|
)
|
|
|
|
if guestMetadata != nil {
|
|
configuration, configurationError := json.Marshal(guestMetadata.RawConfiguration)
|
|
if configurationError != nil {
|
|
return fmt.Errorf("die konfiguration von %q liess sich nicht kodieren: %w",
|
|
foundGuest.Name, configurationError)
|
|
}
|
|
|
|
encodedConfiguration = configuration
|
|
|
|
// Nur wenn die Konfiguration gelesen wurde, gibt es überhaupt eine
|
|
// Aussage. Ohne sie bleibt das Feld NULL — „nicht geprüft" ist etwas
|
|
// anderes als „läuft nicht".
|
|
agentState := guestMetadata.GuestAgentEnabled
|
|
guestAgentRunning = &agentState
|
|
}
|
|
|
|
guestKind := "qemu"
|
|
if foundGuest.GuestType == providers.GuestTypeContainer {
|
|
guestKind = "lxc"
|
|
}
|
|
|
|
const upsertStatement = `
|
|
INSERT INTO virtual_machines (
|
|
cluster_id, host_id, provider_vm_id, name, guest_kind, status,
|
|
cpu_count, memory_bytes, config_json, disks_json, guest_agent_running,
|
|
last_discovered_at, missing_since)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NULL)
|
|
ON CONFLICT (cluster_id, provider_vm_id) DO UPDATE SET
|
|
host_id = EXCLUDED.host_id,
|
|
name = EXCLUDED.name,
|
|
guest_kind = EXCLUDED.guest_kind,
|
|
status = EXCLUDED.status,
|
|
cpu_count = EXCLUDED.cpu_count,
|
|
memory_bytes = EXCLUDED.memory_bytes,
|
|
-- Eine fehlgeschlagene Konfigurationsabfrage darf die zuletzt
|
|
-- bekannte nicht loeschen: Sie ist das, womit eine
|
|
-- Wiederherstellung die Maschine in ihrer Gestalt aufbaut.
|
|
config_json = COALESCE(EXCLUDED.config_json, virtual_machines.config_json),
|
|
disks_json = COALESCE(EXCLUDED.disks_json, virtual_machines.disks_json),
|
|
guest_agent_running = COALESCE(EXCLUDED.guest_agent_running, virtual_machines.guest_agent_running),
|
|
last_discovered_at = NOW(),
|
|
missing_since = NULL,
|
|
updated_at = NOW()`
|
|
|
|
var hostReference *uuid.UUID
|
|
|
|
if hostIdentifier != uuid.Nil {
|
|
hostReference = &hostIdentifier
|
|
}
|
|
|
|
_, executeError := store.connectionPool.Exec(saveContext, upsertStatement,
|
|
clusterIdentifier, hostReference, foundGuest.Identifier, foundGuest.Name, guestKind,
|
|
emptyToNil(string(foundGuest.PowerState)), zeroToNil(foundGuest.CPUCount),
|
|
zeroToNilInt64(foundGuest.MemoryBytes), encodedConfiguration, encodedDisks, guestAgentRunning)
|
|
if executeError != nil {
|
|
return fmt.Errorf("der gast %q liess sich nicht speichern: %w", foundGuest.Name, executeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// markMissingGuests vermerkt Gäste, die bei dieser Aufnahme fehlten.
|
|
//
|
|
// Gelöscht wird nichts. Ein Gast kann abgeschaltet, verschoben oder tatsächlich
|
|
// entfernt worden sein — und eine gelöschte Zeile nähme die Zuordnung zu
|
|
// vorhandenen Backups mit. Wer sie braucht, braucht sie genau dann, wenn die
|
|
// Maschine weg ist.
|
|
func (store *Store) markMissingGuests(markContext context.Context, clusterIdentifier uuid.UUID,
|
|
seenIdentifiers []string) (int, error) {
|
|
// Eine leere Liste muss als leeres Array übergeben werden: `x = ANY(NULL)`
|
|
// ist niemals wahr, und `NOT (x = ANY(NULL))` ist niemals falsch — es ist
|
|
// NULL. Der Fund aus Phase 14, an anderer Stelle.
|
|
if seenIdentifiers == nil {
|
|
seenIdentifiers = []string{}
|
|
}
|
|
|
|
const updateStatement = `
|
|
UPDATE virtual_machines
|
|
SET missing_since = COALESCE(missing_since, NOW()), updated_at = NOW()
|
|
WHERE cluster_id = $1
|
|
AND NOT (provider_vm_id = ANY($2::text[]))
|
|
AND missing_since IS NULL`
|
|
|
|
commandTag, executeError := store.connectionPool.Exec(markContext, updateStatement,
|
|
clusterIdentifier, seenIdentifiers)
|
|
if executeError != nil {
|
|
return 0, fmt.Errorf("die fehlenden gaeste liessen sich nicht vermerken: %w", executeError)
|
|
}
|
|
|
|
return int(commandTag.RowsAffected()), nil
|
|
}
|
|
|
|
// zeroToNilInt64 macht aus einer Null ein NULL.
|
|
func zeroToNilInt64(rawValue int64) *int64 {
|
|
if rawValue == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &rawValue
|
|
}
|