// Kommando syncova-proxmox prüft die Anbindung an einen Proxmox-VE-Verbund. // // Es ist das Werkzeug, mit dem sich der Provider gegen eine echte Umgebung // belegen lässt. Auf dem Entwicklungsrechner steht keine zur Verfügung; ohne // dieses Kommando bliebe die Anbindung allein gegen einen Nachbau geprüft. // // `discover` verändert nichts: alle Aufrufe sind lesend. Das Sichern gehört in // den Auftrag und nicht in ein Werkzeug — die Wiederherstellung eines Gasts // dagegen steht hier, weil sie im Ernstfall gebraucht wird, wenn die // Weboberfläche womöglich gerade nicht läuft. package main import ( "context" "errors" "flag" "fmt" "os" "os/signal" "strings" "syscall" "text/tabwriter" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/providers" "github.com/syncova/syncova/packages/providers/proxmox" ) // serviceName benennt den Dienst in den Protokollen. const serviceName = "syncova-proxmox" // tokenSecretVariable ist die Umgebungsvariable mit dem Tokengeheimnis. // // Das Geheimnis kommt niemals als Aufrufparameter: Aufrufparameter stehen in // der Prozessliste und in der Shell-Historie (PROMPT.md §141). const tokenSecretVariable = "SYNCOVA_PROXMOX_TOKEN_SECRET" // buildVersion wird beim Bauen gesetzt. var buildVersion = "dev" func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, usageText()) os.Exit(2) } var commandError error switch os.Args[1] { case "discover": commandError = runDiscoverCommand(os.Args[2:]) case "clusters": commandError = runListClustersCommand(os.Args[2:]) case "restore-guest": commandError = runRestoreGuestCommand(os.Args[2:]) case "version", "--version", "-version": fmt.Printf("syncova-proxmox %s\n", buildVersion) case "help", "-h", "--help": fmt.Println(usageText()) default: fmt.Fprintf(os.Stderr, "Unbekanntes Kommando: %s\n\n%s\n", os.Args[1], usageText()) os.Exit(2) } if commandError != nil { fmt.Fprintf(os.Stderr, "syncova-proxmox: %v\n", commandError) os.Exit(1) } } // usageText beschreibt die Verwendung. func usageText() string { return `Verwendung: syncova-proxmox discover --url --token Erfasst Verbund, Knoten, Gäste und Platten syncova-proxmox clusters Zeigt die eingerichteten Verbünde syncova-proxmox restore-guest --cluster --repository --backup Stellt einen gesicherten Gast wieder her syncova-proxmox version Zeigt die Version Das Tokengeheimnis kommt aus ` + tokenSecretVariable + ` — niemals als Aufrufparameter. Ein API-Token mit Leserechten genügt für die Erfassung: pveum user add syncova@pve pveum aclmod / --user syncova@pve --role PVEAuditor pveum user token add syncova@pve backup --privsep 0 Die Kommandos clusters und restore-guest brauchen die Control Plane: Die Zugangsdaten der Verbünde liegen verschlüsselt in der Datenbank. Sie lesen dieselben Umgebungsvariablen wie der Dienst (SYNCOVA_DATABASE_*, SYNCOVA_ENCRYPTION_KEYS). Proxmox liefert ab Werk ein selbstsigniertes Zertifikat. Statt die Prüfung abzuschalten, hinterlegen Sie seinen Fingerabdruck mit --fingerprint; er steht in der Weboberfläche unter Certificates.` } // runDiscoverCommand erfasst einen Proxmox-Verbund. func runDiscoverCommand(commandArguments []string) error { commandFlags := flag.NewFlagSet("discover", flag.ContinueOnError) endpointURL := commandFlags.String("url", "", "Adresse des Proxmox-Endpunkts") apiTokenID := commandFlags.String("token", "", "Kennung des API-Tokens, etwa syncova@pve!backup") certificateFingerprint := commandFlags.String("fingerprint", "", "SHA-256-Fingerabdruck des Serverzertifikats") skipTLSVerification := commandFlags.Bool("insecure", false, "Zertifikatsprüfung abschalten (nur für Labore)") showDisks := commandFlags.Bool("disks", false, "Auch die Platten jedes Gasts auflisten") if parseError := commandFlags.Parse(commandArguments); parseError != nil { return parseError } if *endpointURL == "" || *apiTokenID == "" { return errors.New("--url und --token sind erforderlich") } tokenSecret := strings.TrimSpace(os.Getenv(tokenSecretVariable)) if tokenSecret == "" { return fmt.Errorf("das tokengeheimnis muss in %s stehen.\n"+ "Es wird bewusst nicht als Aufrufparameter angenommen: Parameter stehen in der Prozessliste", tokenSecretVariable) } if *certificateFingerprint == "" && !*skipTLSVerification { fmt.Fprintln(os.Stderr, "Hinweis: Ohne --fingerprint wird gegen die Zertifikatsspeicher des Systems geprüft.\n"+ " Das schlägt bei einem selbstsignierten Proxmox-Zertifikat fehl.") } commandLogger := logging.New(os.Stderr, logging.Options{ ServiceName: serviceName, Level: "warn", Format: "text", }) proxmoxProvider, providerError := proxmox.NewProvider(proxmox.ProviderOptions{ ClientOptions: proxmox.ClientOptions{ BaseURL: *endpointURL, APITokenID: *apiTokenID, APITokenSecret: tokenSecret, TLSFingerprintSHA256: *certificateFingerprint, InsecureSkipTLSVerify: *skipTLSVerification, }, }, commandLogger) if providerError != nil { return providerError } discoveryContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignalListener() defer func() { _ = proxmoxProvider.Disconnect() }() if connectError := proxmoxProvider.Connect(discoveryContext); connectError != nil { return connectError } fmt.Printf("Verbunden mit %s\n\n", *endpointURL) return printDiscovery(discoveryContext, proxmoxProvider, *showDisks) } // printDiscovery gibt die Erfassung aus. func printDiscovery(discoveryContext context.Context, proxmoxProvider *proxmox.Provider, showDisks bool) error { discoveredClusters, clusterError := proxmoxProvider.ListClusters(discoveryContext) if clusterError != nil { return clusterError } for _, discoveredCluster := range discoveredClusters { quorumText := "beschlussfähig" if !discoveredCluster.Quorate { // Ein Verbund ohne Quorum nimmt keine ändernden Aufrufe an. Das // jetzt zu wissen erspart eine Reihe unverständlicher Fehler. quorumText = "NICHT beschlussfähig — ändernde Aufrufe werden abgelehnt" } fmt.Printf("Verbund: %s (%d Knoten, %s)\n", discoveredCluster.Name, discoveredCluster.HostCount, quorumText) } discoveredHosts, hostError := proxmoxProvider.ListHosts(discoveryContext, "") if hostError != nil { return hostError } fmt.Println("\nKnoten:") hostWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(hostWriter, " NAME\tZUSTAND\tCPUS\tSPEICHER") for _, discoveredHost := range discoveredHosts { hostState := "erreichbar" if !discoveredHost.Online { hostState = "NICHT ERREICHBAR" } fmt.Fprintf(hostWriter, " %s\t%s\t%d\t%s\n", discoveredHost.Name, hostState, discoveredHost.CPUCount, formatBytes(discoveredHost.MemoryBytes)) } _ = hostWriter.Flush() discoveredGuests, guestError := proxmoxProvider.ListVMs(discoveryContext, "") if guestError != nil { return guestError } fmt.Printf("\nGäste (%d):\n", len(discoveredGuests)) guestWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(guestWriter, " KENNUNG\tNAME\tART\tKNOTEN\tZUSTAND\tETIKETTEN") for _, discoveredGuest := range discoveredGuests { guestKind := "VM" if discoveredGuest.GuestType == providers.GuestTypeContainer { guestKind = "Container" } fmt.Fprintf(guestWriter, " %s\t%s\t%s\t%s\t%s\t%s\n", discoveredGuest.Identifier, discoveredGuest.Name, guestKind, discoveredGuest.HostID, discoveredGuest.PowerState, strings.Join(discoveredGuest.Tags, ", ")) } _ = guestWriter.Flush() if !showDisks { return nil } return printDisks(discoveryContext, proxmoxProvider, discoveredGuests) } // printDisks gibt die Platten aller Gäste aus. func printDisks(diskContext context.Context, proxmoxProvider *proxmox.Provider, discoveredGuests []providers.Guest) error { fmt.Println("\nPlatten:") var excludedDiskCount int for _, discoveredGuest := range discoveredGuests { guestDisks, diskError := proxmoxProvider.GetVMDisks(diskContext, discoveredGuest.Identifier) if diskError != nil { // Ein einzelner unlesbarer Gast darf die Übersicht nicht // verhindern — verschwiegen wird er trotzdem nicht. fmt.Printf(" %s (%s): NICHT LESBAR — %v\n", discoveredGuest.Identifier, discoveredGuest.Name, diskError) continue } fmt.Printf(" %s (%s):\n", discoveredGuest.Identifier, discoveredGuest.Name) for _, guestDisk := range guestDisks { exclusionNote := "" if guestDisk.ExcludedFromBackup { exclusionNote = " ← backup=0: NICHT im Backup enthalten" excludedDiskCount++ } fmt.Printf(" %-10s %-14s %8s %s%s\n", guestDisk.Identifier, guestDisk.StorageID, formatBytes(guestDisk.SizeBytes), guestDisk.Format, exclusionNote) } } if excludedDiskCount > 0 { // Diese Warnung ist der eigentliche Wert der Plattenübersicht: Wer sie // nicht kennt, hält eine unvollständige Maschine für vollständig. fmt.Printf("\nAchtung: %d Platte(n) sind in Proxmox mit backup=0 von der Sicherung ausgenommen.\n"+ "Eine Wiederherstellung ergibt dann eine unvollständige Maschine.\n", excludedDiskCount) } return nil } // byteUnitSuffixes sind die Einheiten der Größenausgabe. var byteUnitSuffixes = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} // formatBytes gibt eine Bytezahl lesbar aus. func formatBytes(byteCount int64) string { if byteCount < 1024 { return fmt.Sprintf("%d B", byteCount) } scaledValue := float64(byteCount) unitIndex := 0 for scaledValue >= 1024 && unitIndex < len(byteUnitSuffixes)-1 { scaledValue /= 1024 unitIndex++ } return fmt.Sprintf("%.1f %s", scaledValue, byteUnitSuffixes[unitIndex]) }