syncova-backup/packages/hypervisor/inventory.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

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
}