Files
phil a9ca649d83 Bugs Fixed
Snapshot names: the old regex allowed names like -x, 1abc and a.b. Proxmox requires a leading letter, 2 to 40 characters, and no periods. A leading - could also have been read as an option flag.
VM IDs: they are now checked against Proxmox’s real range (100 to 999999999) instead of any non-zero number.
Restore: it used to fail confusingly when the ID already existed. It now detects this, warns that the existing guest will be destroyed, and passes --force 1 only if you confirm. The restore archive path must also be absolute.
Dialog sizing: msgbox and yesno had fixed heights that could clip longer text. They are now sized to their content.
reporting: a command that couldn’t run or timed out used to read as “completed with exit status 1”. It now shows the real error.
Other: Replaced strings.Title, which is deprecated. JSON is parsed from stdout only, so stderr warnings can’t break it.

Improvements
Safety
Destructive confirmations (delete, rollback, stop, restore, unlock) default to “No”.
Every state-changing action is logged to /var/log/proxmox-toolkit.log, and you can read the log from the System menu.
Commands now time out instead of hanging the menu.

Usability:
You can now pick VMs, containers, snapshots, storages and backups from lists instead of typing IDs.
Menu and text boxes size themselves to the terminal.
vzdump and restores now stream live output instead of freezing until they finish.
Ctrl-C no longer kills the toolkit and skips temp-dir cleanup.

Code: the VM and container menus shared almost identical code, so they are now one guestMenu(kind).

New features
Guests: force stop, snapshot rollback, and a CPU/memory summary that replaces the memory-only view.
Backup: choose the storage (with free space), mode (snapshot/suspend/stop) and compression.
Restore: pick from a list of existing backups, newest first, with a manual path as a fallback. The archive type is auto-detected, the next free ID is suggested, and you can choose a target storage.
Reports: node status, ZFS pool status, df -h, Proxmox service status, upgradable packages and network addresses.

Removed: the separate “List VM/container names” entries. The list command and the pickers already show names.

Possible next steps:
Clone and migrate (cluster only).
Template conversion.
Reading vzdump retention and schedules from /etc/pve/jobs.cfg.
Splitting the file into packages and adding unit tests. The input validators and the archive-type detection are easy to test.
2026-11-01 16:16:15 +00:00

1604 lines
41 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
version = "V0.4.0"
logPath = "/var/log/proxmox-toolkit.log"
defaultTimeout = 2 * time.Minute
actionTimeout = 10 * time.Minute
)
var (
tmpDir string
nodeName string // the node this program runs on
sigCh = make(chan os.Signal, 1)
// PVE guest IDs are 100..999999999.
numeric = regexp.MustCompile(`^[1-9][0-9]{2,8}$`)
// Snapshot names: start with a letter, 2-40 chars, letters/digits/_/-.
snapName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{1,39}$`)
// Storage IDs must not start with '-' so they can't be parsed as options.
storageName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
nodeRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`)
upidRe = regexp.MustCompile(`^UPID:[A-Za-z0-9:@!._-]+$`)
)
type guestKind struct {
typ string // API type: qemu / lxc
label string // VM / container
title string
}
var (
kindVM = guestKind{"qemu", "VM", "Virtual Machines (QEMU)"}
kindCT = guestKind{"lxc", "container", "Containers (LXC)"}
)
func kindOf(typ string) guestKind {
if typ == "lxc" {
return kindCT
}
return kindVM
}
type guest struct {
ID int
Name string
Status string
Node string
Type string // qemu / lxc
Pool string
Template bool
}
func (g guest) path(suffix string) string {
return fmt.Sprintf("/nodes/%s/%s/%d%s", g.Node, g.Type, g.ID, suffix)
}
func (g guest) short() string {
tag := "VM"
if g.Type == "lxc" {
tag = "CT"
}
return fmt.Sprintf("%d %s [%s] on %s", g.ID, g.Name, tag, g.Node)
}
// ---------------------------------------------------------------- main
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)
// Swallow Ctrl-C in the parent so cleanup still runs; followTask uses it
// to stop following a task.
signal.Notify(sigCh, os.Interrupt)
nodeName = detectNode()
logAction("toolkit %s started on node %s", version, nodeName)
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 "+
"on ANY node in the cluster.\n\n"+
"Actions are logged to "+logPath+".",
)
mainMenu()
logAction("toolkit exited")
}
func detectNode() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "localhost"
}
if i := strings.IndexByte(h, '.'); i >= 0 {
h = h[:i]
}
return h
}
func logAction(format string, a ...any) {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, a...))
}
// ---------------------------------------------------------------- command helpers
func run(timeout time.Duration, name string, args ...string) (string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
status := 0
if err != nil {
status = -1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
status = exitErr.ExitCode()
}
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("timed out after %s", timeout)
status = -1
}
}
return string(out), status, err
}
// runStdout returns only stdout so stderr warnings can't corrupt JSON.
func runStdout(timeout time.Duration, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return exec.CommandContext(ctx, name, args...).Output()
}
func errText(err error) string {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
return strings.TrimSpace(string(exitErr.Stderr))
}
return err.Error()
}
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 waitEnter() {
fmt.Print("Press Enter to return to the menu...")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
}
// ---------------------------------------------------------------- cluster API helpers
// pveshGet runs "pvesh get <path>" and unmarshals the JSON result into v.
func pveshGet(v any, path string, params ...string) error {
args := append([]string{"get", path}, params...)
args = append(args, "--output-format", "json")
out, err := runStdout(60*time.Second, "pvesh", args...)
if err != nil {
return errors.New(errText(err))
}
return json.Unmarshal(out, v)
}
// apiCall runs a state-changing pvesh call (create/set/delete) and returns
// its raw output; for async operations this is the task UPID.
func apiCall(method, path string, params ...string) (string, error) {
args := append([]string{method, path}, params...)
args = append(args, "--output-format", "json")
logAction("api: pvesh %s", strings.Join(args, " "))
out, err := runStdout(defaultTimeout, "pvesh", args...)
if err != nil {
logAction("api failed: %s", errText(err))
return "", errors.New(errText(err))
}
return strings.Trim(strings.TrimSpace(string(out)), `"`), nil
}
type taskStatus struct {
Status string `json:"status"`
ExitStatus string `json:"exitstatus"`
}
func upidNode(upid string) string {
parts := strings.Split(upid, ":")
if len(parts) >= 2 && nodeRe.MatchString(parts[1]) {
return parts[1]
}
return nodeName
}
func taskState(upid string) (taskStatus, error) {
var st taskStatus
err := pveshGet(&st, "/nodes/"+upidNode(upid)+"/tasks/"+upid+"/status")
return st, err
}
func taskLog(upid string, start int) []string {
var raw []struct {
T string `json:"t"`
}
if err := pveshGet(&raw, "/nodes/"+upidNode(upid)+"/tasks/"+upid+"/log",
"--start", strconv.Itoa(start), "--limit", "1000"); err != nil {
return nil
}
lines := make([]string, 0, len(raw))
for _, r := range raw {
lines = append(lines, r.T)
}
return lines
}
// waitTask polls until the task stops (or the timeout passes).
func waitTask(upid string, timeout time.Duration) (taskStatus, string, error) {
deadline := time.Now().Add(timeout)
for {
st, err := taskState(upid)
if err == nil && st.Status == "stopped" {
return st, strings.Join(taskLog(upid, 0), "\n"), nil
}
if time.Now().After(deadline) {
return st, strings.Join(taskLog(upid, 0), "\n"),
fmt.Errorf("task still running after %s (see Cluster > Recent tasks)", timeout)
}
time.Sleep(time.Second)
}
}
// runAPITask performs an API call and shows the resulting task log in a dialog.
func runAPITask(title, method, path string, params ...string) {
out, err := apiCall(method, path, params...)
if err != nil {
displayText(title, "Request failed:\n"+err.Error()+"\n")
return
}
if !upidRe.MatchString(out) {
text := "Completed (no task returned).\n"
if out != "" && out != "null" {
text += out + "\n"
}
displayText(title, text)
return
}
st, log, werr := waitTask(out, actionTimeout)
text := log + "\n"
switch {
case werr != nil:
text += "\n" + werr.Error() + "\n"
case st.ExitStatus == "OK":
text += "\nResult: OK\n"
default:
text += "\nResult: " + st.ExitStatus + "\n"
}
displayText(title, text)
}
// followTask streams a long-running task's log to the terminal.
func followTask(title, upid string) {
select { // drop any stale Ctrl-C
case <-sigCh:
default:
}
fmt.Printf("\033[2J\033[H=== %s ===\nTask: %s\nPress Ctrl-C to stop following (the task keeps running on its node).\n\n", title, upid)
start := 0
for {
lines := taskLog(upid, start)
for _, l := range lines {
fmt.Println(l)
}
start += len(lines)
if st, err := taskState(upid); err == nil && st.Status == "stopped" {
for _, l := range taskLog(upid, start) {
fmt.Println(l)
}
fmt.Printf("\nTask finished: %s\n", st.ExitStatus)
break
}
select {
case <-sigCh:
fmt.Println("\nStopped following. The task is still running.")
waitEnter()
return
case <-time.After(2 * time.Second):
}
}
waitEnter()
}
// startTask issues a call and follows the task it returns.
func startTask(title, method, path string, params ...string) {
out, err := apiCall(method, path, params...)
if err != nil {
displayText(title, "Request failed:\n"+err.Error()+"\n")
return
}
if !upidRe.MatchString(out) {
displayText(title, "Unexpected response (no task ID):\n"+out+"\n")
return
}
followTask(title, out)
}
// ---------------------------------------------------------------- resource discovery
type resource struct {
Type string `json:"type"`
Node string `json:"node"`
Status string `json:"status"`
Name string `json:"name"`
VMID json.Number `json:"vmid"`
Pool string `json:"pool"`
Storage string `json:"storage"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
MaxMem float64 `json:"maxmem"`
Disk float64 `json:"disk"`
MaxDisk float64 `json:"maxdisk"`
Template json.Number `json:"template"`
}
func fetchResources() ([]resource, error) {
var res []resource
if err := pveshGet(&res, "/cluster/resources"); err != nil {
return nil, err
}
return res, nil
}
func guestsFrom(res []resource, typ string) []guest {
var gs []guest
for _, r := range res {
if r.Type != "qemu" && r.Type != "lxc" {
continue
}
if typ != "" && r.Type != typ {
continue
}
id, err := strconv.Atoi(r.VMID.String())
if err != nil || !nodeRe.MatchString(r.Node) {
continue
}
g := guest{ID: id, Name: r.Name, Status: r.Status, Node: r.Node, Type: r.Type, Pool: r.Pool}
if r.Template.String() == "1" {
g.Template = true
g.Status = "template"
}
gs = append(gs, g)
}
sort.Slice(gs, func(i, j int) bool { return gs[i].ID < gs[j].ID })
return gs
}
// listGuests returns guests from the whole cluster; typ "" means VMs and containers.
func listGuests(typ string) ([]guest, error) {
res, err := fetchResources()
if err != nil {
return nil, err
}
return guestsFrom(res, typ), nil
}
func findGuest(id string) (guest, bool) {
gs, err := listGuests("")
if err != nil {
return guest{}, false
}
for _, g := range gs {
if strconv.Itoa(g.ID) == id {
return g, true
}
}
return guest{}, false
}
func guestTable(gs []guest) string {
var b strings.Builder
fmt.Fprintf(&b, "%-8s %-6s %-26s %-10s %-16s %s\n", "ID", "TYPE", "NAME", "STATUS", "NODE", "POOL")
for _, g := range gs {
fmt.Fprintf(&b, "%-8d %-6s %-26.26s %-10s %-16.16s %s\n", g.ID, g.Type, g.Name, g.Status, g.Node, g.Pool)
}
if len(gs) == 0 {
b.WriteString("(none)\n")
}
return b.String()
}
func describeGuests(gs []guest, max int) string {
var lines []string
for i, g := range gs {
if i >= max {
lines = append(lines, fmt.Sprintf("...and %d more", len(gs)-max))
break
}
lines = append(lines, " "+g.short())
}
return strings.Join(lines, "\n")
}
func pickGuest(typ, prompt string) (guest, bool) {
if !requireCommand("pvesh") {
return guest{}, false
}
guests, err := listGuests(typ)
if err != nil {
message("Error", "Could not list guests:\n"+err.Error())
return guest{}, false
}
if len(guests) == 0 {
message("Nothing found", "No matching guests were found in the cluster.")
return guest{}, false
}
var items []string
for _, g := range guests {
tag := "VM"
if g.Type == "lxc" {
tag = "CT"
}
label := fmt.Sprintf("[%s] %s (%s) on %s", tag, g.Name, g.Status, g.Node)
if g.Pool != "" {
label += " {" + g.Pool + "}"
}
items = append(items, strconv.Itoa(g.ID), label)
}
choice := menu("Select guest", prompt, items...)
for _, g := range guests {
if strconv.Itoa(g.ID) == choice {
return g, true
}
}
return guest{}, false
}
func listNodes(onlineOnly bool) ([]string, error) {
var raw []struct {
Node string `json:"node"`
Status string `json:"status"`
}
if err := pveshGet(&raw, "/nodes"); err != nil {
return nil, err
}
var nodes []string
for _, n := range raw {
if !nodeRe.MatchString(n.Node) || (onlineOnly && n.Status != "online") {
continue
}
nodes = append(nodes, n.Node)
}
sort.Strings(nodes)
return nodes, nil
}
// pickNode lets the user choose a node; a single candidate is selected automatically.
func pickNode(prompt, exclude string, onlineOnly bool) (string, bool) {
nodes, err := listNodes(onlineOnly)
if err != nil {
message("Error", "Could not list nodes:\n"+err.Error())
return "", false
}
var items []string
for _, n := range nodes {
if n != exclude {
items = append(items, n, "node")
}
}
if len(items) == 0 {
message("No nodes", "No suitable nodes were found.")
return "", false
}
if len(items) == 2 {
return items[0], true
}
choice := menu("Select node", prompt, items...)
return choice, choice != ""
}
func nextID() string {
out, err := runStdout(15*time.Second, "pvesh", "get", "/cluster/nextid")
if err != nil {
return ""
}
return strings.Trim(strings.TrimSpace(string(out)), `"`)
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// ---------------------------------------------------------------- whiptail wrappers
func boxHeight(text string, width int) int {
lines := 0
for _, l := range strings.Split(text, "\n") {
lines += 1 + len(l)/(width-6)
}
return lines + 6
}
func termSize() (int, int) {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
return 24, 80
}
parts := strings.Fields(string(out))
if len(parts) != 2 {
return 24, 80
}
rows, e1 := strconv.Atoi(parts[0])
cols, e2 := strconv.Atoi(parts[1])
if e1 != nil || e2 != nil || rows <= 0 || cols <= 0 {
return 24, 80
}
return rows, cols
}
func whiptailOK(args ...string) (string, bool) {
readEnd, writeEnd, err := os.Pipe()
if err != nil {
return "", false
}
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 "", false
}
return strings.TrimSpace(result.String()), true
}
func whiptail(args ...string) string {
out, _ := whiptailOK(args...)
return out
}
func menu(title, prompt string, items ...string) string {
n := len(items) / 2
menuHeight := n
if menuHeight > 14 {
menuHeight = 14
}
height := menuHeight + strings.Count(prompt, "\n") + 8
_, cols := termSize()
width := cols - 4
if width > 110 {
width = 110
}
if width < 60 {
width = 60
}
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, strconv.Itoa(boxHeight(text, 70)), "70")
}
func confirmAction(text string, destructive bool) bool {
args := []string{"--title", "Confirm"}
if destructive {
args = append(args, "--defaultno")
}
args = append(args, "--yesno", text, strconv.Itoa(boxHeight(text, 70)), "70")
cmd := exec.Command("whiptail", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run() == nil
}
func inputValue(title, prompt string) (string, bool) {
return whiptailOK("--title", title, "--inputbox", prompt, strconv.Itoa(boxHeight(prompt, 72)+2), "72")
}
func askID(prompt string) (string, bool) {
value, ok := inputValue("Enter ID", prompt)
if !ok {
return "", false
}
if !numeric.MatchString(value) {
message("Invalid ID", "Enter a numeric VM or container ID between 100 and 999999999.")
return "", false
}
return value, true
}
func askSnapshotName() (string, bool) {
value, ok := inputValue("Snapshot name",
"Enter a snapshot name (must start with a letter; letters, digits, _ or - only; max 40 chars):")
if !ok {
return "", false
}
if !snapName.MatchString(value) {
message("Invalid snapshot name",
"Names must start with a letter and contain only letters, digits, underscores and hyphens (2-40 characters).")
return "", false
}
return value, true
}
// ---------------------------------------------------------------- output display
func displayText(title, text string) {
outputPath := filepath.Join(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
}
rows, cols := termSize()
height := rows - 4
width := cols - 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) {
out, status, err := run(defaultTimeout, name, args...)
text := out
if strings.TrimSpace(out) == "" && err == nil {
text = "Command completed successfully; no output.\n"
}
if err != nil {
if status < 0 {
text += "\n" + err.Error() + "\n"
} else {
text += fmt.Sprintf("\nCommand exited with status %d.\n", status)
}
}
displayText(title, text)
}
func showJSON(title, name string, args ...string) {
out, err := runStdout(defaultTimeout, name, args...)
if err != nil {
displayText(title, errText(err)+"\n")
return
}
var formatted bytes.Buffer
if err := json.Indent(&formatted, out, "", " "); err != nil {
displayText(title, "Could not format JSON:\n"+err.Error()+"\n\n"+string(out))
return
}
displayText(title, formatted.String()+"\n")
}
func showSettings(g guest) {
var cfg map[string]any
title := g.short() + " CPU/memory"
if err := pveshGet(&cfg, g.path("/config")); err != nil {
displayText(title, err.Error()+"\n")
return
}
keys := []string{"memory", "balloon", "swap", "cores", "sockets", "vcpus", "cpu", "cpulimit", "cpuunits"}
var lines []string
for _, k := range keys {
if v, ok := cfg[k]; ok {
lines = append(lines, fmt.Sprintf("%-10s %v", k+":", v))
}
}
if len(lines) == 0 {
lines = []string{"No matching settings found."}
}
displayText(title, strings.Join(lines, "\n")+"\n")
}
// ---------------------------------------------------------------- guest menus
func guestMenu(k guestKind) {
for {
choice := menu(
k.title+" - whole cluster", "Choose an action:",
"list", "List all (every node)",
"status", "Show status",
"config", "Show full configuration",
"resources", "Show CPU / memory settings",
"start", "Start",
"shutdown", "Gracefully shut down (60s timeout)",
"reboot", "Reboot",
"stop", "Force stop (like pulling the power)",
"migrate", "Migrate to another node",
"snapshots", "Snapshots",
"unlock", "Unlock",
"back", "Back",
)
switch choice {
case "back", "":
return
case "list":
if !requireCommand("pvesh") {
continue
}
gs, err := listGuests(k.typ)
if err != nil {
message("Error", "Could not list guests:\n"+err.Error())
continue
}
displayText(k.title, guestTable(gs))
default:
g, ok := pickGuest(k.typ, "Select a "+k.label+":")
if ok {
guestAction(g, choice)
}
}
}
}
func guestAction(g guest, action string) {
k := kindOf(g.Type)
name := capitalize(k.label) + " " + strconv.Itoa(g.ID)
switch action {
case "status":
showJSON(name+" status", "pvesh", "get", g.path("/status/current"), "--output-format", "json")
case "config":
showJSON(name+" configuration", "pvesh", "get", g.path("/config"), "--output-format", "json")
case "resources":
showSettings(g)
case "unlock":
if confirmAction("Unlock "+g.short()+"?\n\nOnly do this after confirming no task is still running for it.", true) {
runAPITask("Unlock "+name, "set", g.path("/config"), "--delete", "lock")
}
case "start", "shutdown", "reboot", "stop":
runGuestAction(g, action)
case "migrate":
migrateGuest(g)
case "snapshots":
snapshotMenu(g)
}
}
func runGuestAction(g guest, action string) {
k := kindOf(g.Type)
prompt := fmt.Sprintf("%s %s?", capitalize(action), g.short())
destructive := false
params := []string{}
switch action {
case "shutdown":
prompt += "\n\nThis requests a graceful shutdown and waits up to 60 seconds."
params = []string{"--timeout", "60"}
case "stop":
prompt += "\n\nThis immediately powers off the guest without shutting down the OS. Unsaved data may be lost."
destructive = true
case "reboot":
destructive = true
}
if !confirmAction(prompt, destructive) {
return
}
runAPITask(capitalize(action)+" "+k.label+" "+strconv.Itoa(g.ID), "create", g.path("/status/"+action), params...)
}
func migrateGuest(g guest) {
k := kindOf(g.Type)
target, ok := pickNode("Migrate "+g.short()+" to:", g.Node, true)
if !ok {
return
}
params := []string{"--target", target}
note := ""
if g.Status == "running" {
if g.Type == "qemu" {
params = append(params, "--online", "1")
note = "\n\nThe VM is running and will be live-migrated."
} else {
params = append(params, "--restart", "1")
note = "\n\nThe container is running and will be restarted on the target (containers cannot live-migrate)."
}
}
if !confirmAction("Migrate "+g.short()+" to node "+target+"?"+note+"\n\nLocal disks or devices may block migration.", true) {
return
}
startTask("Migrate "+k.label+" "+strconv.Itoa(g.ID), "create", g.path("/migrate"), params...)
}
func pickSnapshot(g guest, action string) (string, bool) {
var raw []struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := pveshGet(&raw, g.path("/snapshot")); err != nil {
message("Error", "Could not list snapshots:\n"+err.Error())
return "", false
}
var items []string
for _, s := range raw {
if s.Name == "current" || !snapName.MatchString(s.Name) {
continue
}
desc := strings.ReplaceAll(s.Description, "\n", " ")
if desc == "" {
desc = "(no description)"
}
items = append(items, s.Name, desc)
}
if len(items) == 0 {
message("No snapshots", "This guest has no snapshots.")
return "", false
}
name := menu("Select snapshot", "Snapshot to "+action+":", items...)
return name, name != ""
}
func showSnapshots(g guest) {
var raw []struct {
Name string `json:"name"`
SnapTime float64 `json:"snaptime"`
Description string `json:"description"`
}
title := g.short() + " snapshots"
if err := pveshGet(&raw, g.path("/snapshot")); err != nil {
displayText(title, err.Error()+"\n")
return
}
var b strings.Builder
fmt.Fprintf(&b, "%-24s %-17s %s\n", "NAME", "CREATED", "DESCRIPTION")
for _, s := range raw {
created := "-"
if s.SnapTime > 0 {
created = time.Unix(int64(s.SnapTime), 0).Format("2006-01-02 15:04")
}
fmt.Fprintf(&b, "%-24.24s %-17s %s\n", s.Name, created, strings.ReplaceAll(s.Description, "\n", " "))
}
displayText(title, b.String())
}
func snapshotMenu(g guest) {
for {
choice := menu(
g.short()+" snapshots", "Choose an action:",
"list", "List snapshots",
"create", "Create snapshot",
"rollback", "Roll back to a snapshot",
"delete", "Delete snapshot",
"back", "Back",
)
switch choice {
case "list":
showSnapshots(g)
case "create":
name, ok := askSnapshotName()
if !ok {
continue
}
if confirmAction("Create snapshot '"+name+"' for "+g.short()+"?\n\nSnapshots are not a substitute for backups and may consume storage.", false) {
runAPITask("Create snapshot "+name, "create", g.path("/snapshot"), "--snapname", name)
}
case "rollback":
name, ok := pickSnapshot(g, "roll back to")
if !ok {
continue
}
if confirmAction("Roll "+g.short()+" back to snapshot '"+name+"'?\n\nThe current state and any newer snapshots will be lost.", true) {
runAPITask("Rollback to "+name, "create", g.path("/snapshot/"+name+"/rollback"))
}
case "delete":
name, ok := pickSnapshot(g, "delete")
if !ok {
continue
}
if confirmAction("Permanently delete snapshot '"+name+"' from "+g.short()+"?\n\nThis cannot be undone.", true) {
runAPITask("Delete snapshot "+name, "delete", g.path("/snapshot/"+name))
}
case "back", "":
return
}
}
}
// ---------------------------------------------------------------- resource pools
type pool struct {
ID string `json:"poolid"`
Comment string `json:"comment"`
}
func listPools() ([]pool, error) {
var pools []pool
if err := pveshGet(&pools, "/pools"); err != nil {
return nil, err
}
sort.Slice(pools, func(i, j int) bool { return pools[i].ID < pools[j].ID })
return pools, nil
}
func poolMembers(gs []guest, id string) []guest {
var out []guest
for _, g := range gs {
if g.Pool == id {
out = append(out, g)
}
}
return out
}
func pickPool() (string, bool) {
if !requireCommand("pvesh") {
return "", false
}
pools, err := listPools()
if err != nil {
message("Error", "Could not list pools:\n"+err.Error())
return "", false
}
if len(pools) == 0 {
message("No pools", "No resource pools are defined in this cluster.")
return "", false
}
gs, _ := listGuests("")
var items []string
for _, p := range pools {
label := fmt.Sprintf("%d guests", len(poolMembers(gs, p.ID)))
if p.Comment != "" {
label += " - " + strings.ReplaceAll(p.Comment, "\n", " ")
}
items = append(items, p.ID, label)
}
choice := menu("Select pool", "Resource pool:", items...)
return choice, choice != ""
}
func showPools() {
if !requireCommand("pvesh") {
return
}
pools, err := listPools()
if err != nil {
message("Error", "Could not list pools:\n"+err.Error())
return
}
gs, _ := listGuests("")
var b strings.Builder
for _, p := range pools {
fmt.Fprintf(&b, "== %s ==", p.ID)
if p.Comment != "" {
fmt.Fprintf(&b, " (%s)", strings.ReplaceAll(p.Comment, "\n", " "))
}
b.WriteString("\n")
b.WriteString(guestTable(poolMembers(gs, p.ID)))
b.WriteString("\n")
}
if len(pools) == 0 {
b.WriteString("No resource pools are defined.\n")
}
displayText("Resource pools", b.String())
}
// bulkRun issues one API call per guest, then waits for all tasks and reports.
func bulkRun(title string, targets []guest, fn func(guest) (string, error)) {
fmt.Printf("\033[2J\033[H=== %s ===\n\n", title)
logAction("bulk: %s (%d guests)", title, len(targets))
type pending struct {
g guest
upid string
}
var queue []pending
failed := 0
for i, g := range targets {
fmt.Printf("[%d/%d] %s: requesting...\n", i+1, len(targets), g.short())
out, err := fn(g)
switch {
case err != nil:
failed++
fmt.Printf(" FAILED: %s\n", err)
case upidRe.MatchString(out):
queue = append(queue, pending{g, out})
default:
fmt.Println(" done")
}
}
if len(queue) > 0 {
fmt.Println("\nWaiting for tasks to finish...")
}
for _, p := range queue {
st, _, err := waitTask(p.upid, actionTimeout)
switch {
case err != nil:
failed++
fmt.Printf(" %s: %s\n", p.g.short(), err)
case st.ExitStatus != "OK":
failed++
fmt.Printf(" %s: FAILED (%s)\n", p.g.short(), st.ExitStatus)
default:
fmt.Printf(" %s: OK\n", p.g.short())
}
}
fmt.Printf("\nFinished: %d succeeded, %d failed.\n", len(targets)-failed, failed)
waitEnter()
}
func poolAction(action string) {
id, ok := pickPool()
if !ok {
return
}
all, err := listGuests("")
if err != nil {
message("Error", "Could not list guests:\n"+err.Error())
return
}
var targets []guest
for _, g := range poolMembers(all, id) {
switch action {
case "start":
if g.Status == "stopped" && !g.Template {
targets = append(targets, g)
}
case "shutdown":
if g.Status == "running" {
targets = append(targets, g)
}
case "snapshot":
if !g.Template {
targets = append(targets, g)
}
}
}
if len(targets) == 0 {
message("Nothing to do", "No guests in pool '"+id+"' match this action.")
return
}
var fn func(guest) (string, error)
verb := ""
switch action {
case "start":
verb = "Start"
fn = func(g guest) (string, error) { return apiCall("create", g.path("/status/start")) }
case "shutdown":
verb = "Gracefully shut down"
fn = func(g guest) (string, error) {
return apiCall("create", g.path("/status/shutdown"), "--timeout", "60")
}
case "snapshot":
name, ok := askSnapshotName()
if !ok {
return
}
verb = "Snapshot '" + name + "' on"
fn = func(g guest) (string, error) { return apiCall("create", g.path("/snapshot"), "--snapname", name) }
}
text := fmt.Sprintf("%s these %d guests in pool '%s'?\n\n%s", verb, len(targets), id, describeGuests(targets, 12))
if !confirmAction(text, action != "start") {
return
}
bulkRun(verb+" pool "+id, targets, fn)
}
func poolsMenu() {
for {
choice := menu(
"Resource Pools", "Choose an action (applies across all nodes):",
"list", "List pools and members",
"start", "Start all stopped guests in a pool",
"shutdown", "Gracefully shut down all running guests in a pool",
"snapshot", "Snapshot all guests in a pool",
"back", "Back",
)
switch choice {
case "list":
showPools()
case "start", "shutdown", "snapshot":
poolAction(choice)
case "back", "":
return
}
}
}
// ---------------------------------------------------------------- backup / restore
func pickBackupStorage(node string) (string, bool) {
var raw []struct {
Storage string `json:"storage"`
Type string `json:"type"`
Avail float64 `json:"avail"`
}
if err := pveshGet(&raw, "/nodes/"+node+"/storage", "--content", "backup", "--enabled", "1"); err != nil {
message("Storage error", "Could not list backup storage on "+node+":\n"+err.Error())
return "", false
}
var items []string
for _, s := range raw {
if !storageName.MatchString(s.Storage) {
continue
}
items = append(items, s.Storage, fmt.Sprintf("%s, %.1f GiB free", s.Type, s.Avail/(1<<30)))
}
if len(items) == 0 {
message("No backup storage", "No enabled storage with content type 'backup' on node "+node+".")
return "", false
}
choice := menu("Backup storage ("+node+")", "Select a storage:", items...)
return choice, choice != ""
}
func backupMenu() {
g, ok := pickGuest("", "Select the VM or container to back up:")
if !ok {
return
}
storage, ok := pickBackupStorage(g.Node)
if !ok {
return
}
mode := menu("Backup mode", "Choose a backup mode:",
"snapshot", "Snapshot - guest keeps running (recommended)",
"suspend", "Suspend - brief pause while copying",
"stop", "Stop - shut down, back up, restart",
)
if mode == "" {
return
}
compress := menu("Compression", "Choose compression:",
"zstd", "zstd (fast, good ratio)",
"gzip", "gzip",
"lzo", "lzo (fastest, larger)",
"0", "None",
)
if compress == "" {
return
}
summary := fmt.Sprintf("Back up %s?\n\nStorage: %s\nMode: %s\nCompression: %s", g.short(), storage, mode, compress)
if !confirmAction(summary, false) {
return
}
startTask("Backup "+g.short(), "create", "/nodes/"+g.Node+"/vzdump",
"--vmid", strconv.Itoa(g.ID), "--storage", storage, "--mode", mode, "--compress", compress)
}
func manualArchive() (string, bool) {
archive, ok := inputValue("Backup archive", "Enter the full path to the vzdump archive:")
if !ok {
return "", false
}
info, err := os.Stat(archive)
if archive == "" || !filepath.IsAbs(archive) || err != nil || !info.Mode().IsRegular() {
message("Archive not found", "Enter an absolute path to an existing regular file.")
return "", false
}
return archive, true
}
func pickBackup(node, storage string) (string, bool) {
var raw []struct {
VolID string `json:"volid"`
Size float64 `json:"size"`
CTime float64 `json:"ctime"`
}
err := pveshGet(&raw, "/nodes/"+node+"/storage/"+storage+"/content", "--content", "backup")
local := node == nodeName // manual paths can only be checked on this node
if err != nil || len(raw) == 0 {
if local {
message("No backups listed", "No backups were found on '"+storage+"'. You can enter an archive path manually.")
return manualArchive()
}
message("No backups", "No backups were found on '"+storage+"' ("+node+").")
return "", false
}
sort.Slice(raw, func(i, j int) bool { return raw[i].CTime > raw[j].CTime })
var items []string
for i, b := range raw {
base := b.VolID
if idx := strings.LastIndexByte(base, '/'); idx >= 0 && !strings.Contains(base, "backup/vm/") && !strings.Contains(base, "backup/ct/") {
base = base[idx+1:]
}
items = append(items, strconv.Itoa(i+1), fmt.Sprintf("%s %.1f GiB", base, b.Size/(1<<30)))
}
if local {
items = append(items, "manual", "Enter an archive path manually...")
}
choice := menu("Select backup", "Newest first:", items...)
switch choice {
case "":
return "", false
case "manual":
return manualArchive()
}
idx, err := strconv.Atoi(choice)
if err != nil || idx < 1 || idx > len(raw) {
return "", false
}
return raw[idx-1].VolID, true
}
func detectArchiveType(archive string) string {
switch {
case strings.Contains(archive, "vzdump-qemu-"), strings.Contains(archive, "backup/vm/"):
return "qemu"
case strings.Contains(archive, "vzdump-lxc-"), strings.Contains(archive, "backup/ct/"):
return "lxc"
}
return ""
}
func restoreMenu() {
if !requireCommand("pvesh") {
return
}
prompt := "Enter the target VMID / container ID:"
if n := nextID(); n != "" {
prompt = "Enter the target VMID / container ID (next free ID: " + n + "):"
}
id, ok := askID(prompt)
if !ok {
return
}
// An existing guest can only be overwritten on the node it lives on.
existing, exists := findGuest(id)
var node string
if exists {
if !confirmAction("ID "+id+" already exists:\n "+existing.short()+"\n\nRestoring will DESTROY and overwrite it. Continue?", true) {
return
}
node = existing.Node
} else if node, ok = pickNode("Restore on which node?", "", true); !ok {
return
}
storage, ok := pickBackupStorage(node)
if !ok {
return
}
archive, ok := pickBackup(node, storage)
if !ok {
return
}
typ := detectArchiveType(archive)
if typ == "" {
typ = menu("Archive type", "Could not detect the archive type. What is it?",
"qemu", "QEMU VM",
"lxc", "Container (LXC)",
)
if typ == "" {
return
}
}
if exists && existing.Type != typ {
message("Type mismatch", "The existing guest is a "+kindOf(existing.Type).label+
" but the archive is a "+kindOf(typ).label+". Choose a different ID.")
return
}
target, ok := inputValue("Target storage", "Storage for the restored disks (leave blank for the default):")
if !ok {
return
}
if target != "" && !storageName.MatchString(target) {
message("Invalid storage", "Storage names may only contain letters, digits, _, . and -, and must start with a letter or digit.")
return
}
if !confirmAction("Restore '"+archive+"' as ID "+id+" on node "+node+"?", true) {
return
}
params := []string{"--vmid", id}
if typ == "qemu" {
params = append(params, "--archive", archive)
} else {
params = append(params, "--ostemplate", archive, "--restore", "1")
}
if target != "" {
params = append(params, "--storage", target)
}
if exists {
params = append(params, "--force", "1")
}
startTask("Restore ID "+id, "create", "/nodes/"+node+"/"+typ, params...)
}
// ---------------------------------------------------------------- reports
func clusterOverview() {
if !requireCommand("pvesh") {
return
}
res, err := fetchResources()
if err != nil {
message("Error", "Could not read cluster resources:\n"+err.Error())
return
}
gib := func(v float64) float64 { return v / (1 << 30) }
var b strings.Builder
b.WriteString("NODES\n")
fmt.Fprintf(&b, "%-16s %-9s %-7s %-20s %s\n", "NODE", "STATUS", "CPU", "MEMORY (GiB)", "VMs/CTs running")
gs := guestsFrom(res, "")
for _, r := range res {
if r.Type != "node" {
continue
}
running, total := 0, 0
for _, g := range gs {
if g.Node == r.Node {
total++
if g.Status == "running" {
running++
}
}
}
fmt.Fprintf(&b, "%-16.16s %-9s %5.1f%% %6.1f / %-10.1f %d/%d\n",
r.Node, r.Status, r.CPU*100, gib(r.Mem), gib(r.MaxMem), running, total)
}
b.WriteString("\nSTORAGE\n")
fmt.Fprintf(&b, "%-20s %-16s %-10s %s\n", "STORAGE", "NODE", "STATUS", "USED")
for _, r := range res {
if r.Type != "storage" {
continue
}
pct := 0.0
if r.MaxDisk > 0 {
pct = r.Disk / r.MaxDisk * 100
}
fmt.Fprintf(&b, "%-20.20s %-16.16s %-10s %5.1f%% of %.0f GiB\n", r.Storage, r.Node, r.Status, pct, gib(r.MaxDisk))
}
b.WriteString("\nGUESTS\n")
b.WriteString(guestTable(gs))
displayText("Cluster overview (all nodes)", b.String())
}
func storageMenu() {
for {
choice := menu(
"Storage (this node)", "Choose a report:",
"status", "Proxmox storage status (pvesm status)",
"pvs", "Physical volumes (pvs)",
"vgs", "Volume groups (vgs)",
"lvs", "Logical volumes (lvs)",
"zfs", "ZFS pool status (zpool status)",
"df", "Filesystem usage (df -h)",
"back", "Back",
)
switch choice {
case "status":
if requireCommand("pvesm") {
showOutput("Proxmox storage status", "pvesm", "status")
}
case "pvs", "vgs", "lvs":
if requireCommand(choice) {
showOutput(choice, choice)
}
case "zfs":
if requireCommand("zpool") {
showOutput("ZFS pool status", "zpool", "status")
}
case "df":
showOutput("Filesystem usage", "df", "-h")
case "back", "":
return
}
}
}
func resourcesMenu() {
for {
choice := menu(
"Cluster Resources",
"Choose a resource type to display as formatted JSON:",
"vm", "Virtual machines and containers",
"storage", "Storage",
"node", "Nodes",
"sdn", "SDN",
"pool", "Pools",
"back", "Back",
)
switch choice {
case "vm", "storage", "node", "sdn", "pool":
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:",
"overview", "Cluster overview: nodes, storage, all guests",
"status", "Cluster status (pvecm status)",
"nodes", "List nodes (JSON)",
"nodestatus", "Node status: CPU, memory, uptime",
"tasks", "Recent cluster tasks",
"back", "Back",
)
switch choice {
case "overview":
clusterOverview()
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 "nodestatus":
if !requireCommand("pvesh") {
continue
}
if n, ok := pickNode("Show status for:", "", false); ok {
showJSON("Node "+n+" status", "pvesh", "get", "/nodes/"+n+"/status", "--output-format", "json")
}
case "tasks":
if requireCommand("pvesh") {
showJSON("Recent cluster tasks", "pvesh", "get", "/cluster/tasks", "--output-format", "json")
}
case "back", "":
return
}
}
}
func systemMenu() {
for {
choice := menu(
"System (this node)", "Choose a report:",
"services", "Proxmox service status",
"updates", "Upgradable packages (as of last apt update)",
"network", "Network addresses (ip -br address)",
"log", "Toolkit action log",
"back", "Back",
)
switch choice {
case "services":
showOutput("Proxmox services", "systemctl", "status",
"pve-cluster", "pvedaemon", "pveproxy", "pvestatd", "--no-pager", "--lines=0")
case "updates":
if requireCommand("apt") {
showOutput("Upgradable packages", "apt", "list", "--upgradable")
}
case "network":
showOutput("Network addresses", "ip", "-br", "address")
case "log":
showOutput("Toolkit log (last 200 lines)", "tail", "-n", "200", logPath)
case "back", "":
return
}
}
}
// ---------------------------------------------------------------- main menu
func mainMenu() {
for {
choice := menu(
"Proxmox VE Toolkit - DEVELOPMENT ONLY - NOT FOR PRODUCTION "+version,
"Select a task (running on node: "+nodeName+"):",
"vms", "Virtual machines (QEMU) - whole cluster",
"containers", "Containers (LXC) - whole cluster",
"pools", "Resource pools - bulk actions",
"backup", "Back up a VM or container (vzdump)",
"restore", "Restore a VM or container",
"cluster", "Cluster overview and reports",
"storage", "Storage / LVM / ZFS reports (this node)",
"system", "System: services, updates, network, log",
"resources", "Cluster resources (formatted JSON)",
"version", "Proxmox version (pveversion -v)",
"perf", "Host performance test (pveperf)",
"subscription", "Subscription status",
"dab", "Debian Appliance Builder help",
"quit", "Exit",
)
switch choice {
case "vms":
guestMenu(kindVM)
case "containers":
guestMenu(kindCT)
case "pools":
poolsMenu()
case "backup":
backupMenu()
case "restore":
restoreMenu()
case "cluster":
clusterMenu()
case "storage":
storageMenu()
case "system":
systemMenu()
case "resources":
resourcesMenu()
case "version":
if requireCommand("pveversion") {
showOutput("Proxmox VE version", "pveversion", "-v")
}
case "perf":
if requireCommand("pveperf") &&
confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.", false) {
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 "quit", "":
return
}
}
}