syncova-backup/apps/api/cmd/syncova-proxmox/guest.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

222 lines
7.8 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"text/tabwriter"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/hypervisor"
"github.com/syncova/syncova/packages/platform/config"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/database"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/providers"
)
// runListClustersCommand zeigt die eingerichteten Verbünde.
func runListClustersCommand(commandArguments []string) error {
commandFlags := flag.NewFlagSet("clusters", flag.ContinueOnError)
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return parseError
}
commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignalListener()
environment, buildError := buildGuestEnvironment(commandContext)
if buildError != nil {
return buildError
}
defer environment.close()
clusterList, listError := environment.clusterStore.ListClusters(commandContext)
if listError != nil {
return listError
}
if len(clusterList) == 0 {
fmt.Println("Es ist kein Virtualisierungsverbund eingerichtet.")
fmt.Println("Richten Sie einen über POST /api/v1/proxmox/clusters ein.")
return nil
}
outputWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(outputWriter, "KENNUNG\tNAME\tADRESSE\tZUGRIFFSWEG\tZUSTAND")
for _, singleCluster := range clusterList {
fmt.Fprintf(outputWriter, "%s\t%s\t%s\t%s\t%s\n",
singleCluster.ID, singleCluster.Name, singleCluster.APIEndpoint,
singleCluster.ArchiveTransport, singleCluster.Status)
}
return outputWriter.Flush()
}
// runRestoreGuestCommand stellt einen gesicherten Gast wieder her.
//
// Das ist das Werkzeug für den verpflichtenden Meilenstein der Phase 7: VM
// entdecken → sichern → verifizieren → Test-VM löschen → wiederherstellen →
// **booten** → validieren. Die letzten beiden Schritte kann keine Software
// belegen, die keinen echten Knoten hat.
func runRestoreGuestCommand(commandArguments []string) error {
commandFlags := flag.NewFlagSet("restore-guest", flag.ContinueOnError)
clusterIdentifier := commandFlags.String("cluster", "", "Kennung des Zielverbunds")
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
backupIdentifier := commandFlags.String("backup", "", "Kennung des Backups im Repository")
targetGuest := commandFlags.String("target-guest", "",
"Zielkennung, etwa qemu/900 — ohne Angabe wird der Ursprungsgast überschrieben")
targetNode := commandFlags.String("node", "", "Zielknoten; ohne Angabe der Ursprungsknoten")
targetStorage := commandFlags.String("storage", "", "Zielspeicher für die Platten")
overwriteExisting := commandFlags.Bool("overwrite", false,
"Einen vorhandenen Gast überschreiben — vernichtet dessen aktuellen Stand")
startAfterRestore := commandFlags.Bool("start", false,
"Den Gast nach der Wiederherstellung starten")
keepStagedArchive := commandFlags.Bool("keep-archive", false,
"Das bereitgestellte Archiv auf dem Knoten liegen lassen")
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *clusterIdentifier == "" || *repositoryPath == "" || *backupIdentifier == "" {
return errors.New("--cluster, --repository und --backup sind erforderlich")
}
parsedClusterID, parseError := uuid.Parse(*clusterIdentifier)
if parseError != nil {
return fmt.Errorf("die verbundkennung %q ist keine gueltige uuid", *clusterIdentifier)
}
// Überschreiben verlangt eine ausdrückliche Bestätigung.
//
// Dieselbe Überlegung wie bei der Wiederherstellung von Dateien (Phase 9):
// Ein versehentlich gesetztes Kennzeichen in einem Skript darf nicht
// genügen, um einen laufenden Gast zu ersetzen.
if *overwriteExisting && *targetGuest == "" {
fmt.Fprintln(os.Stderr,
"WARNUNG: Ohne --target-guest wird der Ursprungsgast überschrieben.\n"+
" Sein aktueller Stand geht dabei verloren.")
}
commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignalListener()
environment, buildError := buildGuestEnvironment(commandContext)
if buildError != nil {
return buildError
}
defer environment.close()
restoreResult, restoreError := environment.clusterStore.RestoreGuest(commandContext,
hypervisor.GuestRestoreRequest{
ClusterID: parsedClusterID,
RepositoryPath: *repositoryPath,
BackupID: *backupIdentifier,
TargetGuestID: *targetGuest,
TargetNode: *targetNode,
TargetStorageID: *targetStorage,
OverwriteExisting: *overwriteExisting,
StartAfterRestore: *startAfterRestore,
KeepStagedArchive: *keepStagedArchive,
ProgressCallback: func(currentProgress providers.RestoreProgress) {
fmt.Printf(" [%3.0f %%] %s — %s\n",
currentProgress.PercentComplete, currentProgress.Stage, currentProgress.Message)
},
}, environment.secretStore, environment.logger)
if restoreError != nil {
return restoreError
}
fmt.Printf("\nWiederhergestellt: %s auf Knoten %s\n", restoreResult.GuestID, restoreResult.NodeName)
fmt.Printf("Bereitgestellt: %s (%s)\n",
formatBytes(restoreResult.BytesStaged), restoreResult.ArchiveVolume)
fmt.Printf("Dauer: %s\n", restoreResult.Duration.Round(1e6))
if restoreResult.Started {
fmt.Println("Zustand: gestartet")
} else {
// Nicht gestartet ist kein Mangel, sondern die Vorgabe. Der Satz steht
// da, damit niemand auf die laufende Maschine wartet.
fmt.Println("Zustand: angehalten — starten Sie ihn, wenn das Netz dafür bereit ist")
}
for _, singleWarning := range restoreResult.Warnings {
fmt.Printf("Hinweis: %s\n", singleWarning)
}
return nil
}
// guestCommandEnvironment hält die für die Gastbefehle nötigen Dienste.
type guestCommandEnvironment struct {
// logger schreibt die Protokollzeilen.
logger *slog.Logger
// databasePool ist der Verbindungspool; er muss geschlossen werden.
databasePool *database.Pool
// clusterStore verwaltet die Verbünde.
clusterStore *hypervisor.Store
// secretStore entschlüsselt die Daten im Repository.
secretStore crypto.SecretStore
}
// close gibt die Betriebsmittel frei.
func (environment *guestCommandEnvironment) close() {
environment.databasePool.Close()
}
// buildGuestEnvironment lädt Konfiguration, Datenbank und Schlüsselmaterial.
//
// Anders als `discover` brauchen diese Befehle die Control Plane: Die
// Zugangsdaten des Verbunds liegen verschlüsselt in der Datenbank, und ohne
// Schlüsselmaterial lässt sich weder das API-Token entschlüsseln noch das
// Backup lesen.
func buildGuestEnvironment(setupContext context.Context) (*guestCommandEnvironment, error) {
serviceConfig, configError := config.Load(serviceName)
if configError != nil {
return nil, configError
}
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceConfig.ServiceName,
Level: serviceConfig.Logging.Level,
Format: serviceConfig.Logging.Format,
})
secretStore, secretStoreError := crypto.NewLocalSecretStore(
serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion)
if secretStoreError != nil {
return nil, fmt.Errorf("die verschluesselung konnte nicht eingerichtet werden: %w", secretStoreError)
}
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
if databaseError != nil {
return nil, databaseError
}
clusterStore, storeError := hypervisor.NewStore(databasePool.Connections(), secretStore)
if storeError != nil {
databasePool.Close()
return nil, storeError
}
return &guestCommandEnvironment{
logger: commandLogger,
databasePool: databasePool,
clusterStore: clusterStore,
secretStore: secretStore,
}, nil
}