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>
222 lines
7.8 KiB
Go
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
|
|
}
|