2026-10-29 21:55:16 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2026-11-01 16:16:15 +00:00
|
|
|
"bufio"
|
2026-10-29 21:55:16 +00:00
|
|
|
"bytes"
|
2026-11-01 16:16:15 +00:00
|
|
|
"context"
|
2026-10-29 22:16:19 +00:00
|
|
|
"encoding/json"
|
2026-10-29 22:14:05 +00:00
|
|
|
"errors"
|
2026-10-29 21:55:16 +00:00
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
2026-11-01 16:16:15 +00:00
|
|
|
"os/signal"
|
|
|
|
|
"path/filepath"
|
2026-10-29 21:55:16 +00:00
|
|
|
"regexp"
|
2026-11-01 16:16:15 +00:00
|
|
|
"sort"
|
2026-10-29 22:14:05 +00:00
|
|
|
"strconv"
|
2026-10-29 21:55:16 +00:00
|
|
|
"strings"
|
2026-11-01 16:16:15 +00:00
|
|
|
"time"
|
2026-10-29 21:55:16 +00:00
|
|
|
)
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-29 22:16:19 +00:00
|
|
|
|
2026-10-29 21:55:16 +00:00
|
|
|
var (
|
2026-11-01 16:16:15 +00:00
|
|
|
kindVM = guestKind{"qemu", "VM", "Virtual Machines (QEMU)"}
|
|
|
|
|
kindCT = guestKind{"lxc", "container", "Containers (LXC)"}
|
2026-10-29 21:55:16 +00:00
|
|
|
)
|
2026-10-29 22:16:19 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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
|
|
|
|
|
|
2026-10-29 21:55:16 +00:00
|
|
|
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)
|
2026-11-01 16:16:15 +00:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-10-29 22:46:41 +00:00
|
|
|
message(
|
2026-11-01 16:16:15 +00:00
|
|
|
"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+".",
|
2026-10-29 22:46:41 +00:00
|
|
|
)
|
2026-10-29 21:55:16 +00:00
|
|
|
mainMenu()
|
2026-11-01 16:16:15 +00:00
|
|
|
logAction("toolkit exited")
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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")
|
2026-10-29 21:55:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-29 21:55:16 +00:00
|
|
|
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()
|
2026-10-29 22:14:05 +00:00
|
|
|
_ = writeEnd.Close()
|
2026-10-29 21:55:16 +00:00
|
|
|
|
|
|
|
|
var result bytes.Buffer
|
|
|
|
|
_, _ = result.ReadFrom(readEnd)
|
|
|
|
|
if err != nil {
|
2026-11-01 16:16:15 +00:00
|
|
|
return "", false
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
return strings.TrimSpace(result.String()), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func whiptail(args ...string) string {
|
|
|
|
|
out, _ := whiptailOK(args...)
|
|
|
|
|
return out
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 22:14:05 +00:00
|
|
|
args := []string{
|
|
|
|
|
"--title", title,
|
|
|
|
|
"--menu", prompt,
|
|
|
|
|
strconv.Itoa(height),
|
|
|
|
|
strconv.Itoa(width),
|
|
|
|
|
strconv.Itoa(menuHeight),
|
|
|
|
|
}
|
|
|
|
|
args = append(args, items...)
|
|
|
|
|
return whiptail(args...)
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-29 22:14:05 +00:00
|
|
|
func message(title, text string) {
|
2026-11-01 16:16:15 +00:00
|
|
|
whiptail("--title", title, "--msgbox", text, strconv.Itoa(boxHeight(text, 70)), "70")
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func confirmAction(text string, destructive bool) bool {
|
|
|
|
|
args := []string{"--title", "Confirm"}
|
|
|
|
|
if destructive {
|
|
|
|
|
args = append(args, "--defaultno")
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
args = append(args, "--yesno", text, strconv.Itoa(boxHeight(text, 70)), "70")
|
2026-10-29 21:55:16 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
cmd := exec.Command("whiptail", args...)
|
2026-10-29 21:55:16 +00:00
|
|
|
cmd.Stdin = os.Stdin
|
|
|
|
|
cmd.Stdout = os.Stdout
|
|
|
|
|
cmd.Stderr = os.Stderr
|
2026-11-01 16:16:15 +00:00
|
|
|
return cmd.Run() == nil
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func inputValue(title, prompt string) (string, bool) {
|
|
|
|
|
return whiptailOK("--title", title, "--inputbox", prompt, strconv.Itoa(boxHeight(prompt, 72)+2), "72")
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func askID(prompt string) (string, bool) {
|
|
|
|
|
value, ok := inputValue("Enter ID", prompt)
|
|
|
|
|
if !ok {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
if !numeric.MatchString(value) {
|
|
|
|
|
message("Invalid ID", "Enter a numeric VM or container ID between 100 and 999999999.")
|
2026-10-29 22:46:41 +00:00
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return value, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func askSnapshotName() (string, bool) {
|
2026-11-01 16:16:15 +00:00
|
|
|
value, ok := inputValue("Snapshot name",
|
|
|
|
|
"Enter a snapshot name (must start with a letter; letters, digits, _ or - only; max 40 chars):")
|
2026-10-29 22:46:41 +00:00
|
|
|
if !ok {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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).")
|
2026-10-29 21:55:16 +00:00
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return value, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
// ---------------------------------------------------------------- output display
|
2026-10-29 22:14:05 +00:00
|
|
|
|
2026-10-29 22:46:41 +00:00
|
|
|
func displayText(title, text string) {
|
2026-11-01 16:16:15 +00:00
|
|
|
outputPath := filepath.Join(tmpDir, "output.txt")
|
2026-10-29 22:46:41 +00:00
|
|
|
if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil {
|
|
|
|
|
message("Error", "Could not write command output to the temporary file.")
|
2026-10-29 21:55:16 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
rows, cols := termSize()
|
|
|
|
|
height := rows - 4
|
|
|
|
|
width := cols - 4
|
2026-10-29 22:46:41 +00:00
|
|
|
if height < 12 {
|
|
|
|
|
height = 12
|
|
|
|
|
}
|
|
|
|
|
if width < 50 {
|
|
|
|
|
width = 50
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
2026-10-29 21:55:16 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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")
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "start", "shutdown", "reboot", "stop":
|
|
|
|
|
runGuestAction(g, action)
|
|
|
|
|
case "migrate":
|
|
|
|
|
migrateGuest(g)
|
|
|
|
|
case "snapshots":
|
|
|
|
|
snapshotMenu(g)
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
2026-10-29 22:14:05 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func runGuestAction(g guest, action string) {
|
|
|
|
|
k := kindOf(g.Type)
|
|
|
|
|
prompt := fmt.Sprintf("%s %s?", capitalize(action), g.short())
|
|
|
|
|
destructive := false
|
|
|
|
|
params := []string{}
|
2026-10-29 22:14:05 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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) {
|
2026-10-29 22:46:41 +00:00
|
|
|
return
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
runAPITask(capitalize(action)+" "+k.label+" "+strconv.Itoa(g.ID), "create", g.path("/status/"+action), params...)
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func migrateGuest(g guest) {
|
|
|
|
|
k := kindOf(g.Type)
|
|
|
|
|
target, ok := pickNode("Migrate "+g.short()+" to:", g.Node, true)
|
|
|
|
|
if !ok {
|
2026-10-29 22:14:05 +00:00
|
|
|
return
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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)."
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
if !confirmAction("Migrate "+g.short()+" to node "+target+"?"+note+"\n\nLocal disks or devices may block migration.", true) {
|
|
|
|
|
return
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
startTask("Migrate "+k.label+" "+strconv.Itoa(g.ID), "create", g.path("/migrate"), params...)
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func pickSnapshot(g guest, action string) (string, bool) {
|
|
|
|
|
var raw []struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Description string `json:"description"`
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
if err := pveshGet(&raw, g.path("/snapshot")); err != nil {
|
|
|
|
|
message("Error", "Could not list snapshots:\n"+err.Error())
|
|
|
|
|
return "", false
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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)
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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 != ""
|
|
|
|
|
}
|
2026-10-29 22:16:19 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func showSnapshots(g guest) {
|
|
|
|
|
var raw []struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
SnapTime float64 `json:"snaptime"`
|
|
|
|
|
Description string `json:"description"`
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
title := g.short() + " snapshots"
|
|
|
|
|
if err := pveshGet(&raw, g.path("/snapshot")); err != nil {
|
|
|
|
|
displayText(title, err.Error()+"\n")
|
2026-10-29 22:16:19 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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())
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func snapshotMenu(g guest) {
|
2026-10-29 21:55:16 +00:00
|
|
|
for {
|
2026-10-29 22:14:05 +00:00
|
|
|
choice := menu(
|
2026-11-01 16:16:15 +00:00
|
|
|
g.short()+" snapshots", "Choose an action:",
|
|
|
|
|
"list", "List snapshots",
|
|
|
|
|
"create", "Create snapshot",
|
|
|
|
|
"rollback", "Roll back to a snapshot",
|
|
|
|
|
"delete", "Delete snapshot",
|
2026-10-29 21:55:16 +00:00
|
|
|
"back", "Back",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
switch choice {
|
|
|
|
|
case "list":
|
2026-11-01 16:16:15 +00:00
|
|
|
showSnapshots(g)
|
|
|
|
|
case "create":
|
|
|
|
|
name, ok := askSnapshotName()
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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)
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "rollback":
|
|
|
|
|
name, ok := pickSnapshot(g, "roll back to")
|
|
|
|
|
if !ok {
|
2026-10-29 21:55:16 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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")
|
2026-10-29 21:55:16 +00:00
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
if confirmAction("Permanently delete snapshot '"+name+"' from "+g.short()+"?\n\nThis cannot be undone.", true) {
|
|
|
|
|
runAPITask("Delete snapshot "+name, "delete", g.path("/snapshot/"+name))
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
case "back", "":
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
// ---------------------------------------------------------------- resource pools
|
2026-10-29 21:55:16 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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)
|
2026-10-29 22:16:19 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "shutdown":
|
|
|
|
|
if g.Status == "running" {
|
|
|
|
|
targets = append(targets, g)
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "snapshot":
|
|
|
|
|
if !g.Template {
|
|
|
|
|
targets = append(targets, g)
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(targets) == 0 {
|
|
|
|
|
message("Nothing to do", "No guests in pool '"+id+"' match this action.")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-29 22:14:05 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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 {
|
2026-10-29 22:46:41 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
verb = "Snapshot '" + name + "' on"
|
|
|
|
|
fn = func(g guest) (string, error) { return apiCall("create", g.path("/snapshot"), "--snapname", name) }
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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") {
|
2026-10-29 22:46:41 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
bulkRun(verb+" pool "+id, targets, fn)
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
func poolsMenu() {
|
2026-10-29 22:46:41 +00:00
|
|
|
for {
|
|
|
|
|
choice := menu(
|
2026-11-01 16:16:15 +00:00
|
|
|
"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",
|
2026-10-29 22:46:41 +00:00
|
|
|
"back", "Back",
|
|
|
|
|
)
|
|
|
|
|
switch choice {
|
|
|
|
|
case "list":
|
2026-11-01 16:16:15 +00:00
|
|
|
showPools()
|
|
|
|
|
case "start", "shutdown", "snapshot":
|
|
|
|
|
poolAction(choice)
|
2026-10-29 21:55:16 +00:00
|
|
|
case "back", "":
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
// ---------------------------------------------------------------- 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 != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 21:55:16 +00:00
|
|
|
func backupMenu() {
|
2026-11-01 16:16:15 +00:00
|
|
|
g, ok := pickGuest("", "Select the VM or container to back up:")
|
|
|
|
|
if !ok {
|
2026-10-29 21:55:16 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
storage, ok := pickBackupStorage(g.Node)
|
2026-10-29 21:55:16 +00:00
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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"
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
return ""
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func restoreMenu() {
|
2026-11-01 16:16:15 +00:00
|
|
|
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 {
|
2026-10-29 21:55:16 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
storage, ok := pickBackupStorage(node)
|
2026-10-29 21:55:16 +00:00
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
archive, ok := pickBackup(node, storage)
|
2026-10-29 21:55:16 +00:00
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-29 22:14:05 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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.")
|
2026-10-29 21:55:16 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
|
|
|
|
|
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())
|
2026-10-29 21:55:16 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-29 21:55:16 +00:00
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
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
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
fmt.Fprintf(&b, "%-20.20s %-16.16s %-10s %5.1f%% of %.0f GiB\n", r.Storage, r.Node, r.Status, pct, gib(r.MaxDisk))
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
|
|
|
|
|
b.WriteString("\nGUESTS\n")
|
|
|
|
|
b.WriteString(guestTable(gs))
|
|
|
|
|
displayText("Cluster overview (all nodes)", b.String())
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func storageMenu() {
|
|
|
|
|
for {
|
2026-10-29 22:14:05 +00:00
|
|
|
choice := menu(
|
2026-11-01 16:16:15 +00:00
|
|
|
"Storage (this node)", "Choose a report:",
|
2026-10-29 22:46:41 +00:00
|
|
|
"status", "Proxmox storage status (pvesm status)",
|
2026-10-29 21:55:16 +00:00
|
|
|
"pvs", "Physical volumes (pvs)",
|
|
|
|
|
"vgs", "Volume groups (vgs)",
|
|
|
|
|
"lvs", "Logical volumes (lvs)",
|
2026-11-01 16:16:15 +00:00
|
|
|
"zfs", "ZFS pool status (zpool status)",
|
|
|
|
|
"df", "Filesystem usage (df -h)",
|
2026-10-29 21:55:16 +00:00
|
|
|
"back", "Back",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
switch choice {
|
2026-10-29 22:46:41 +00:00
|
|
|
case "status":
|
|
|
|
|
if requireCommand("pvesm") {
|
|
|
|
|
showOutput("Proxmox storage status", "pvesm", "status")
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "pvs", "vgs", "lvs":
|
|
|
|
|
if requireCommand(choice) {
|
|
|
|
|
showOutput(choice, choice)
|
2026-10-29 22:14:05 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "zfs":
|
|
|
|
|
if requireCommand("zpool") {
|
|
|
|
|
showOutput("ZFS pool status", "zpool", "status")
|
2026-10-29 21:55:16 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "df":
|
|
|
|
|
showOutput("Filesystem usage", "df", "-h")
|
2026-10-29 21:55:16 +00:00
|
|
|
case "back", "":
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 22:46:41 +00:00
|
|
|
func resourcesMenu() {
|
|
|
|
|
for {
|
|
|
|
|
choice := menu(
|
|
|
|
|
"Cluster Resources",
|
|
|
|
|
"Choose a resource type to display as formatted JSON:",
|
2026-11-01 16:16:15 +00:00
|
|
|
"vm", "Virtual machines and containers",
|
2026-10-29 22:46:41 +00:00
|
|
|
"storage", "Storage",
|
|
|
|
|
"node", "Nodes",
|
|
|
|
|
"sdn", "SDN",
|
2026-11-01 16:16:15 +00:00
|
|
|
"pool", "Pools",
|
2026-10-29 22:46:41 +00:00
|
|
|
"back", "Back",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
switch choice {
|
2026-11-01 16:16:15 +00:00
|
|
|
case "vm", "storage", "node", "sdn", "pool":
|
2026-10-29 22:46:41 +00:00
|
|
|
if requireCommand("pvesh") {
|
2026-11-01 16:16:15 +00:00
|
|
|
showJSON("Cluster "+choice+" resources", "pvesh",
|
|
|
|
|
"get", "/cluster/resources", "--type", choice, "--output-format", "json")
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
|
|
|
|
case "back", "":
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clusterMenu() {
|
|
|
|
|
for {
|
|
|
|
|
choice := menu(
|
2026-11-01 16:16:15 +00:00
|
|
|
"Cluster and Node", "Choose a report:",
|
|
|
|
|
"overview", "Cluster overview: nodes, storage, all guests",
|
2026-10-29 22:46:41 +00:00
|
|
|
"status", "Cluster status (pvecm status)",
|
2026-11-01 16:16:15 +00:00
|
|
|
"nodes", "List nodes (JSON)",
|
|
|
|
|
"nodestatus", "Node status: CPU, memory, uptime",
|
2026-10-29 22:46:41 +00:00
|
|
|
"tasks", "Recent cluster tasks",
|
|
|
|
|
"back", "Back",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
switch choice {
|
2026-11-01 16:16:15 +00:00
|
|
|
case "overview":
|
|
|
|
|
clusterOverview()
|
2026-10-29 22:46:41 +00:00
|
|
|
case "status":
|
|
|
|
|
if requireCommand("pvecm") {
|
|
|
|
|
showOutput("Cluster status", "pvecm", "status")
|
|
|
|
|
}
|
|
|
|
|
case "nodes":
|
|
|
|
|
if requireCommand("pvesh") {
|
|
|
|
|
showJSON("Cluster nodes", "pvesh", "get", "/nodes", "--output-format", "json")
|
|
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
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")
|
|
|
|
|
}
|
2026-10-29 22:46:41 +00:00
|
|
|
case "tasks":
|
|
|
|
|
if requireCommand("pvesh") {
|
2026-11-01 16:16:15 +00:00
|
|
|
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")
|
2026-10-29 22:46:41 +00:00
|
|
|
}
|
2026-11-01 16:16:15 +00:00
|
|
|
case "network":
|
|
|
|
|
showOutput("Network addresses", "ip", "-br", "address")
|
|
|
|
|
case "log":
|
|
|
|
|
showOutput("Toolkit log (last 200 lines)", "tail", "-n", "200", logPath)
|
2026-10-29 22:46:41 +00:00
|
|
|
case "back", "":
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-01 16:16:15 +00:00
|
|
|
// ---------------------------------------------------------------- main menu
|
2026-10-29 22:46:41 +00:00
|
|
|
|
2026-10-29 21:55:16 +00:00
|
|
|
func mainMenu() {
|
|
|
|
|
for {
|
2026-10-29 22:14:05 +00:00
|
|
|
choice := menu(
|
2026-10-29 22:46:41 +00:00
|
|
|
"Proxmox VE Toolkit - DEVELOPMENT ONLY - NOT FOR PRODUCTION "+version,
|
2026-11-01 16:16:15 +00:00
|
|
|
"Select a task (running on node: "+nodeName+"):",
|
|
|
|
|
"vms", "Virtual machines (QEMU) - whole cluster",
|
|
|
|
|
"containers", "Containers (LXC) - whole cluster",
|
|
|
|
|
"pools", "Resource pools - bulk actions",
|
2026-10-29 21:55:16 +00:00
|
|
|
"backup", "Back up a VM or container (vzdump)",
|
|
|
|
|
"restore", "Restore a VM or container",
|
2026-11-01 16:16:15 +00:00
|
|
|
"cluster", "Cluster overview and reports",
|
|
|
|
|
"storage", "Storage / LVM / ZFS reports (this node)",
|
|
|
|
|
"system", "System: services, updates, network, log",
|
|
|
|
|
"resources", "Cluster resources (formatted JSON)",
|
2026-10-29 21:55:16 +00:00
|
|
|
"version", "Proxmox version (pveversion -v)",
|
|
|
|
|
"perf", "Host performance test (pveperf)",
|
|
|
|
|
"subscription", "Subscription status",
|
|
|
|
|
"dab", "Debian Appliance Builder help",
|
|
|
|
|
"quit", "Exit",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
switch choice {
|
|
|
|
|
case "vms":
|
2026-11-01 16:16:15 +00:00
|
|
|
guestMenu(kindVM)
|
2026-10-29 21:55:16 +00:00
|
|
|
case "containers":
|
2026-11-01 16:16:15 +00:00
|
|
|
guestMenu(kindCT)
|
|
|
|
|
case "pools":
|
|
|
|
|
poolsMenu()
|
2026-10-29 21:55:16 +00:00
|
|
|
case "backup":
|
|
|
|
|
backupMenu()
|
|
|
|
|
case "restore":
|
|
|
|
|
restoreMenu()
|
|
|
|
|
case "cluster":
|
2026-10-29 22:46:41 +00:00
|
|
|
clusterMenu()
|
2026-11-01 16:16:15 +00:00
|
|
|
case "storage":
|
|
|
|
|
storageMenu()
|
|
|
|
|
case "system":
|
|
|
|
|
systemMenu()
|
|
|
|
|
case "resources":
|
|
|
|
|
resourcesMenu()
|
2026-10-29 21:55:16 +00:00
|
|
|
case "version":
|
|
|
|
|
if requireCommand("pveversion") {
|
|
|
|
|
showOutput("Proxmox VE version", "pveversion", "-v")
|
|
|
|
|
}
|
|
|
|
|
case "perf":
|
2026-10-29 22:14:05 +00:00
|
|
|
if requireCommand("pveperf") &&
|
2026-11-01 16:16:15 +00:00
|
|
|
confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.", false) {
|
2026-10-29 21:55:16 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|