Files
pve-toolkit/Go/main.go
T

653 lines
15 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
)
const version = "V0.2.0"
var (
tmpDir string
numeric = regexp.MustCompile(`^[0-9]+$`)
snapName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
)
func main() {
if os.Geteuid() != 0 {
fmt.Fprintf(os.Stderr, "Run this program as root, for example: sudo %s\n", os.Args[0])
os.Exit(1)
}
if _, err := exec.LookPath("whiptail"); err != nil {
fmt.Fprintln(os.Stderr, "whiptail is required. Install it with: apt install whiptail")
os.Exit(1)
}
var err error
tmpDir, err = os.MkdirTemp("", "proxmox-toolkit-*")
if err != nil {
fmt.Fprintf(os.Stderr, "Could not create temporary directory: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tmpDir)
message(
"Development software — do not use in production",
"This toolkit is still in development and has not been validated for production use.\n\n" +
"Use only in a test environment. Actions may affect VMs, containers, storage, or cluster state.",
)
mainMenu()
}
func whiptail(args ...string) string {
readEnd, writeEnd, err := os.Pipe()
if err != nil {
return ""
}
defer readEnd.Close()
args = append([]string{"--output-fd", "3"}, args...)
cmd := exec.Command("whiptail", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{writeEnd}
err = cmd.Run()
_ = writeEnd.Close()
var result bytes.Buffer
_, _ = result.ReadFrom(readEnd)
if err != nil {
return ""
}
return strings.TrimSpace(result.String())
}
func menu(title, prompt string, height, width, menuHeight int, items ...string) string {
args := []string{
"--title", title,
"--menu", prompt,
strconv.Itoa(height),
strconv.Itoa(width),
strconv.Itoa(menuHeight),
}
args = append(args, items...)
return whiptail(args...)
}
func message(title, text string) {
whiptail("--title", title, "--msgbox", text, "8", "70")
}
func confirmAction(text string) bool {
cmd := exec.Command("whiptail", "--title", "Confirm", "--yesno", text, "10", "70")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run() == nil
}
func inputValue(title, prompt string) (string, bool) {
readEnd, writeEnd, err := os.Pipe()
if err != nil {
return "", false
}
defer readEnd.Close()
cmd := exec.Command(
"whiptail",
"--output-fd", "3",
"--title", title,
"--inputbox", prompt,
"9", "72",
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{writeEnd}
err = cmd.Run()
_ = writeEnd.Close()
var result bytes.Buffer
_, _ = result.ReadFrom(readEnd)
if err != nil {
return "", false
}
return strings.TrimSpace(result.String()), true
}
func requireCommand(name string) bool {
if _, err := exec.LookPath(name); err != nil {
message(
"Command unavailable",
fmt.Sprintf("The command '%s' is not installed or not available on this node.", name),
)
return false
}
return true
}
func askID(prompt string) (string, bool) {
value, ok := inputValue("Enter ID", prompt)
if !ok {
return "", false
}
if !numeric.MatchString(value) || value == "0" {
message("Invalid ID", "Enter a non-zero numeric VM or container ID.")
return "", false
}
return value, true
}
func askSnapshotName() (string, bool) {
value, ok := inputValue("Snapshot name", "Enter a snapshot name (letters, digits, _, ., or -):")
if !ok {
return "", false
}
if value == "" || !snapName.MatchString(value) {
message("Invalid snapshot name", "Use only letters, digits, underscores, periods, and hyphens.")
return "", false
}
return value, true
}
func terminalSize(name string, fallback int) int {
out, err := exec.Command("tput", name).Output()
if err != nil {
return fallback
}
value, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil || value <= 0 {
return fallback
}
return value
}
func displayText(title, text string) {
outputPath := tmpDir + "/output.txt"
if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil {
message("Error", "Could not write command output to the temporary file.")
return
}
height := terminalSize("lines", 24) - 4
width := terminalSize("cols", 80) - 4
if height < 12 {
height = 12
}
if width < 50 {
width = 50
}
whiptail("--scrolltext", "--title", title, "--textbox", outputPath,
strconv.Itoa(height), strconv.Itoa(width))
}
func showOutput(title, name string, args ...string) {
output, err := exec.Command(name, args...).CombinedOutput()
status := 0
if err != nil {
status = 1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
status = exitErr.ExitCode()
}
}
text := string(output)
if text == "" {
text = fmt.Sprintf("Command completed with exit status %d; no output.\n", status)
} else if status != 0 {
text += fmt.Sprintf("\nCommand exited with status %d.\n", status)
}
displayText(title, text)
}
func showJSON(title, name string, args ...string) {
output, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
text := string(output)
if text == "" {
text = err.Error()
}
displayText(title, text)
return
}
var formatted bytes.Buffer
if err := json.Indent(&formatted, output, "", " "); err != nil {
displayText(title, "Could not format JSON:\n"+err.Error()+"\n\n"+string(output))
return
}
displayText(title, formatted.String()+"\n")
}
func showMemory(title, id string) {
output, err := exec.Command("qm", "config", id).CombinedOutput()
if err != nil {
displayText(title, string(output)+"\n"+err.Error())
return
}
var matches []string
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "memory:") {
matches = append(matches, line)
}
}
if len(matches) == 0 {
matches = []string{"No memory setting found."}
}
displayText(title, strings.Join(matches, "\n")+"\n")
}
func showResourceNames(title, resourceType string) {
output, err := exec.Command(
"pvesh", "get", "/cluster/resources", "--output-format", "json",
).CombinedOutput()
if err != nil {
displayText(title, string(output))
return
}
var resources []struct {
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.Unmarshal(output, &resources); err != nil {
displayText(title, "Could not parse pvesh output:\n"+err.Error())
return
}
var names []string
for _, resource := range resources {
if resource.Type == resourceType {
names = append(names, resource.Name)
}
}
if len(names) == 0 {
displayText(title, "No matching resources found.\n")
return
}
displayText(title, strings.Join(names, "\n")+"\n")
}
func vmMenu() {
for {
choice := menu(
"Virtual Machines (QEMU)", "Choose an action:", 20, 76, 12,
"list", "List VMs (qm list)",
"names", "List VM names",
"status", "Show VM status",
"config", "Show VM configuration",
"memory", "Show VM memory setting",
"start", "Start a VM",
"shutdown", "Gracefully shut down a VM",
"reboot", "Reboot a VM",
"snapshots", "VM snapshots",
"unlock", "Unlock a VM",
"back", "Back",
)
switch choice {
case "list":
if requireCommand("qm") {
showOutput("VM list", "qm", "list")
}
case "names":
if requireCommand("pvesh") {
showResourceNames("VM names", "qemu")
}
case "status", "config", "memory", "unlock", "start", "shutdown", "reboot", "snapshots":
if !requireCommand("qm") {
continue
}
id, ok := askID("Enter the VMID:")
if !ok {
continue
}
switch choice {
case "status":
showOutput("VM "+id+" status", "qm", "status", id)
case "config":
showOutput("VM "+id+" configuration", "qm", "config", id)
case "memory":
showMemory("VM "+id+" memory", id)
case "unlock":
if confirmAction("Unlock VM " + id + "?\n\nOnly do this after confirming no task is still running for this VM.") {
showOutput("Unlock VM "+id, "qm", "unlock", id)
}
case "start", "shutdown", "reboot":
runGuestAction("qm", id, choice, "VM")
case "snapshots":
snapshotMenu("qm", id, "VM")
}
case "back", "":
return
}
}
}
func containerMenu() {
for {
choice := menu(
"Containers (LXC)", "Choose an action:", 18, 76, 10,
"list", "List containers (pct list)",
"names", "List container names",
"status", "Show container status",
"config", "Show container configuration",
"start", "Start a container",
"shutdown", "Gracefully shut down a container",
"reboot", "Reboot a container",
"snapshots", "Container snapshots",
"unlock", "Unlock a container",
"back", "Back",
)
switch choice {
case "list":
if requireCommand("pct") {
showOutput("Container list", "pct", "list")
}
case "names":
if requireCommand("pvesh") {
showResourceNames("Container names", "lxc")
}
case "status", "config", "unlock", "start", "shutdown", "reboot", "snapshots":
if !requireCommand("pct") {
continue
}
id, ok := askID("Enter the container ID:")
if !ok {
continue
}
switch choice {
case "status":
showOutput("Container "+id+" status", "pct", "status", id)
case "config":
showOutput("Container "+id+" configuration", "pct", "config", id)
case "unlock":
if confirmAction("Unlock container " + id + "?\n\nOnly do this after confirming no task is still running for this container.") {
showOutput("Unlock container "+id, "pct", "unlock", id)
}
case "start", "shutdown", "reboot":
runGuestAction("pct", id, choice, "container")
case "snapshots":
snapshotMenu("pct", id, "container")
}
case "back", "":
return
}
}
}
func runGuestAction(command, id, action, kind string) {
prompt := fmt.Sprintf("%s %s %s?", action, kind, id)
if action == "shutdown" {
prompt += "\n\nThis requests a graceful shutdown."
}
if !confirmAction(prompt) {
return
}
showOutput(strings.Title(action)+" "+kind+" "+id, command, action, id)
}
func snapshotMenu(command, id, kind string) {
for {
choice := menu(
kind+" snapshots",
"Choose an action:",
12, 72, 5,
"list", "List snapshots",
"create", "Create snapshot",
"delete", "Delete snapshot",
"back", "Back",
)
switch choice {
case "list":
showOutput(kind+" "+id+" snapshots", command, "listsnapshot", id)
case "create":
name, ok := askSnapshotName()
if !ok {
continue
}
if confirmAction("Create snapshot '" + name + "' for " + kind + " " + id + "?\n\nSnapshots are not a substitute for backups and may consume storage.") {
showOutput("Create snapshot "+name, command, "snapshot", id, name)
}
case "delete":
name, ok := askSnapshotName()
if !ok {
continue
}
if confirmAction("Permanently delete snapshot '" + name + "' from " + kind + " " + id + "?\n\nThis cannot be undone.") {
showOutput("Delete snapshot "+name, command, "delsnapshot", id, name)
}
case "back", "":
return
}
}
}
func backupMenu() {
if !requireCommand("vzdump") {
return
}
id, ok := askID("Enter the VM or container ID to back up:")
if !ok {
return
}
if confirmAction("Start a backup for ID " + id + " using vzdump defaults?\n\nReview backup configuration and storage capacity first.") {
showOutput("Backup ID "+id, "vzdump", id)
}
}
func restoreMenu() {
choice := menu(
"Restore", "Choose what to restore:", 12, 72, 4,
"vm", "Restore a QEMU VM (qmrestore)",
"ct", "Restore a container (pct restore)",
"back", "Back",
)
if choice != "vm" && choice != "ct" {
return
}
id, ok := askID("Enter the new VMID or container ID:")
if !ok {
return
}
archive, ok := inputValue("Backup archive", "Enter the full path to the vzdump archive:")
if !ok {
return
}
info, err := os.Stat(archive)
if archive == "" || err != nil || !info.Mode().IsRegular() {
message("Archive not found", "That file does not exist or is not a regular file.")
return
}
if !confirmAction("Restore '" + archive + "' as ID " + id + "?\n\nConfirm the target ID and archive carefully.") {
return
}
if choice == "vm" {
if requireCommand("qmrestore") {
showOutput("Restore VM "+id, "qmrestore", archive, id)
}
} else if requireCommand("pct") {
showOutput("Restore container "+id, "pct", "restore", id, archive)
}
}
func storageMenu() {
for {
choice := menu(
"Storage", "Choose a report:", 14, 72, 6,
"status", "Proxmox storage status (pvesm status)",
"pvs", "Physical volumes (pvs)",
"vgs", "Volume groups (vgs)",
"lvs", "Logical volumes (lvs)",
"back", "Back",
)
switch choice {
case "status":
if requireCommand("pvesm") {
showOutput("Proxmox storage status", "pvesm", "status")
}
case "pvs":
if requireCommand("pvs") {
showOutput("Physical volumes", "pvs")
}
case "vgs":
if requireCommand("vgs") {
showOutput("Volume groups", "vgs")
}
case "lvs":
if requireCommand("lvs") {
showOutput("Logical volumes", "lvs")
}
case "back", "":
return
}
}
}
func resourcesMenu() {
for {
choice := menu(
"Cluster Resources",
"Choose a resource type to display as formatted JSON:",
16, 76, 8,
"vm", "Virtual machines",
"storage", "Storage",
"node", "Nodes",
"sdn", "SDN",
"back", "Back",
)
switch choice {
case "vm", "storage", "node", "sdn":
if requireCommand("pvesh") {
showJSON(
"Cluster "+choice+" resources",
"pvesh",
"get", "/cluster/resources",
"--type", choice,
"--output-format", "json",
)
}
case "back", "":
return
}
}
}
func clusterMenu() {
for {
choice := menu(
"Cluster and Node",
"Choose a report:",
14, 72, 6,
"status", "Cluster status (pvecm status)",
"nodes", "List nodes",
"tasks", "Recent cluster tasks",
"back", "Back",
)
switch choice {
case "status":
if requireCommand("pvecm") {
showOutput("Cluster status", "pvecm", "status")
}
case "nodes":
if requireCommand("pvesh") {
showJSON("Cluster nodes", "pvesh", "get", "/nodes", "--output-format", "json")
}
case "tasks":
if requireCommand("pvesh") {
showJSON(
"Recent cluster tasks",
"pvesh", "get", "/cluster/tasks",
"--limit", "50",
"--output-format", "json",
)
}
case "back", "":
return
}
}
}
func mainMenu() {
for {
choice := menu(
"Proxmox VE Toolkit - DEVELOPMENT ONLY - NOT FOR PRODUCTION "+version,
"Select a task:",
22, 76, 14,
"vms", "Virtual machines (QEMU)",
"containers", "Containers (LXC)",
"backup", "Back up a VM or container (vzdump)",
"restore", "Restore a VM or container",
"cluster", "Cluster and node reports",
"version", "Proxmox version (pveversion -v)",
"storage", "Storage / LVM reports",
"perf", "Host performance test (pveperf)",
"subscription", "Subscription status",
"dab", "Debian Appliance Builder help",
"resources", "Cluster resources (formatted JSON)",
"quit", "Exit",
)
switch choice {
case "vms":
vmMenu()
case "containers":
containerMenu()
case "backup":
backupMenu()
case "restore":
restoreMenu()
case "cluster":
clusterMenu()
case "version":
if requireCommand("pveversion") {
showOutput("Proxmox VE version", "pveversion", "-v")
}
case "storage":
storageMenu()
case "perf":
if requireCommand("pveperf") &&
confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") {
showOutput("Host performance", "pveperf")
}
case "subscription":
if requireCommand("pvesubscription") {
showOutput("Subscription status", "pvesubscription", "get")
}
case "dab":
if requireCommand("dab") {
showOutput("Debian Appliance Builder help", "dab", "help")
}
case "resources":
resourcesMenu()
case "quit", "":
return
}
}
}