updated to 0.2.0. New features include VM and container status and controls, snapshot management, Proxmox storage status, recent cluster tasks, and node listings.

This commit is contained in:
phil committed 2026-10-29 22:46:41 +00:00
1 parent 3ff81eaf02
commit e35d299418
2 files changed
+252 -181

No files matched your search

+252 -181
View File
@@ -12,11 +12,12 @@ import (
"strings" "strings"
) )
const version = "V0.1.4" const version = "V0.2.0"
var ( var (
tmpDir string tmpDir string
numeric = regexp.MustCompile(`^[0-9]+$`) numeric = regexp.MustCompile(`^[0-9]+$`)
snapName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
) )
func main() { func main() {
@@ -37,12 +38,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
message(
"Development software — do not use in production",
"This toolkit is still in development and has not been validated for production use.\n\n" +
"Use only in a test environment. Actions may affect VMs, containers, storage, or cluster state.",
)
mainMenu() mainMenu()
} }
// whiptail runs a dialog and captures its selected value through file
// descriptor 3. The dialog itself uses the terminal.
func whiptail(args ...string) string { func whiptail(args ...string) string {
readEnd, writeEnd, err := os.Pipe() readEnd, writeEnd, err := os.Pipe()
if err != nil { if err != nil {
@@ -85,26 +88,11 @@ func message(title, text string) {
} }
func confirmAction(text string) bool { func confirmAction(text string) bool {
readEnd, writeEnd, err := os.Pipe() cmd := exec.Command("whiptail", "--title", "Confirm", "--yesno", text, "10", "70")
if err != nil {
return false
}
defer readEnd.Close()
cmd := exec.Command(
"whiptail",
"--title", "Confirm",
"--yesno", text,
"10", "70",
)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{writeEnd} return cmd.Run() == nil
err = cmd.Run()
_ = writeEnd.Close()
return err == nil
} }
func inputValue(title, prompt string) (string, bool) { func inputValue(title, prompt string) (string, bool) {
@@ -153,8 +141,20 @@ func askID(prompt string) (string, bool) {
if !ok { if !ok {
return "", false return "", false
} }
if !numeric.MatchString(value) { if !numeric.MatchString(value) || value == "0" {
message("Invalid ID", "Enter a numeric VM or container ID.") message("Invalid ID", "Enter a non-zero numeric VM or container ID.")
return "", false
}
return value, true
}
func askSnapshotName() (string, bool) {
value, ok := inputValue("Snapshot name", "Enter a snapshot name (letters, digits, _, ., or -):")
if !ok {
return "", false
}
if value == "" || !snapName.MatchString(value) {
message("Invalid snapshot name", "Use only letters, digits, underscores, periods, and hyphens.")
return "", false return "", false
} }
return value, true return value, true
@@ -173,100 +173,7 @@ func terminalSize(name string, fallback int) int {
return value return value
} }
func showOutput(title string, name string, args ...string) { func displayText(title, text string) {
outputPath := tmpDir + "/output.txt"
output, err := os.Create(outputPath)
if err != nil {
message("Error", "Could not create a temporary output file.")
return
}
cmd := exec.Command(name, args...)
cmd.Stdout = output
cmd.Stderr = output
runErr := cmd.Run()
_ = output.Close()
status := 0
if runErr != nil {
status = 1
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
status = exitErr.ExitCode()
}
}
data, _ := os.ReadFile(outputPath)
if len(data) == 0 {
data = []byte(fmt.Sprintf(
"Command completed with exit status %d; no output.\n",
status,
))
} else if status != 0 {
data = append(data, []byte(fmt.Sprintf(
"\nCommand exited with status %d.\n",
status,
))...)
}
if err := os.WriteFile(outputPath, data, 0600); err != nil {
message("Error", "Could not write command output to the temporary file.")
return
}
// Leave room around the dialog and use fallback dimensions if tput fails.
height := terminalSize("lines", 24) - 4
width := terminalSize("cols", 80) - 4
// Keep dimensions within whiptail's practical limits.
if height < 12 {
height = 12
}
if width < 50 {
width = 50
}
whiptail(
"--scrolltext",
"--title", title,
"--textbox", outputPath,
strconv.Itoa(height),
strconv.Itoa(width),
)
}
func showMemory(title, id string) {
output, err := exec.Command("qm", "config", id).CombinedOutput()
if err != nil {
status := 1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
status = exitErr.ExitCode()
}
text := string(output)
if text != "" {
text += "\n"
}
text += fmt.Sprintf("Command exited with status %d.\n", status)
showText(title, text)
return
}
var matches []string
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "memory:") {
matches = append(matches, line)
}
}
if len(matches) == 0 {
matches = []string{"No memory setting found."}
}
showText(title, strings.Join(matches, "\n")+"\n")
}
func showText(title, text string) {
outputPath := tmpDir + "/output.txt" outputPath := tmpDir + "/output.txt"
if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil { if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil {
message("Error", "Could not write command output to the temporary file.") message("Error", "Could not write command output to the temporary file.")
@@ -282,13 +189,66 @@ func showText(title, text string) {
width = 50 width = 50
} }
whiptail( whiptail("--scrolltext", "--title", title, "--textbox", outputPath,
"--scrolltext", strconv.Itoa(height), strconv.Itoa(width))
"--title", title, }
"--textbox", outputPath,
strconv.Itoa(height), func showOutput(title, name string, args ...string) {
strconv.Itoa(width), output, err := exec.Command(name, args...).CombinedOutput()
) status := 0
if err != nil {
status = 1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
status = exitErr.ExitCode()
}
}
text := string(output)
if text == "" {
text = fmt.Sprintf("Command completed with exit status %d; no output.\n", status)
} else if status != 0 {
text += fmt.Sprintf("\nCommand exited with status %d.\n", status)
}
displayText(title, text)
}
func showJSON(title, name string, args ...string) {
output, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
text := string(output)
if text == "" {
text = err.Error()
}
displayText(title, text)
return
}
var formatted bytes.Buffer
if err := json.Indent(&formatted, output, "", " "); err != nil {
displayText(title, "Could not format JSON:\n"+err.Error()+"\n\n"+string(output))
return
}
displayText(title, formatted.String()+"\n")
}
func showMemory(title, id string) {
output, err := exec.Command("qm", "config", id).CombinedOutput()
if err != nil {
displayText(title, string(output)+"\n"+err.Error())
return
}
var matches []string
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "memory:") {
matches = append(matches, line)
}
}
if len(matches) == 0 {
matches = []string{"No memory setting found."}
}
displayText(title, strings.Join(matches, "\n")+"\n")
} }
func showResourceNames(title, resourceType string) { func showResourceNames(title, resourceType string) {
@@ -296,11 +256,7 @@ func showResourceNames(title, resourceType string) {
"pvesh", "get", "/cluster/resources", "--output-format", "json", "pvesh", "get", "/cluster/resources", "--output-format", "json",
).CombinedOutput() ).CombinedOutput()
if err != nil { if err != nil {
text := string(output) displayText(title, string(output))
if text == "" {
text = err.Error()
}
showText(title, text)
return return
} }
@@ -309,7 +265,7 @@ func showResourceNames(title, resourceType string) {
Name string `json:"name"` Name string `json:"name"`
} }
if err := json.Unmarshal(output, &resources); err != nil { if err := json.Unmarshal(output, &resources); err != nil {
showText(title, "Could not parse pvesh output:\n"+err.Error()) displayText(title, "Could not parse pvesh output:\n"+err.Error())
return return
} }
@@ -319,25 +275,26 @@ func showResourceNames(title, resourceType string) {
names = append(names, resource.Name) names = append(names, resource.Name)
} }
} }
if len(names) == 0 { if len(names) == 0 {
showText(title, "No matching resources found.\n") displayText(title, "No matching resources found.\n")
return return
} }
displayText(title, strings.Join(names, "\n")+"\n")
showText(title, strings.Join(names, "\n")+"\n")
} }
func vmMenu() { func vmMenu() {
for { for {
choice := menu( choice := menu(
"Virtual Machines (QEMU)", "Virtual Machines (QEMU)", "Choose an action:", 20, 76, 12,
"Choose an action:",
18, 72, 10,
"list", "List VMs (qm list)", "list", "List VMs (qm list)",
"names", "List VM names", "names", "List VM names",
"status", "Show VM status",
"config", "Show VM configuration", "config", "Show VM configuration",
"memory", "Show VM memory setting", "memory", "Show VM memory setting",
"start", "Start a VM",
"shutdown", "Gracefully shut down a VM",
"reboot", "Reboot a VM",
"snapshots", "VM snapshots",
"unlock", "Unlock a VM", "unlock", "Unlock a VM",
"back", "Back", "back", "Back",
) )
@@ -351,28 +308,30 @@ func vmMenu() {
if requireCommand("pvesh") { if requireCommand("pvesh") {
showResourceNames("VM names", "qemu") showResourceNames("VM names", "qemu")
} }
case "config", "memory", "unlock": case "status", "config", "memory", "unlock", "start", "shutdown", "reboot", "snapshots":
if !requireCommand("qm") { if !requireCommand("qm") {
continue continue
} }
id, ok := askID("Enter the VMID:") id, ok := askID("Enter the VMID:")
if !ok { if !ok {
continue continue
} }
switch choice { switch choice {
case "status":
showOutput("VM "+id+" status", "qm", "status", id)
case "config": case "config":
showOutput("VM "+id+" configuration", "qm", "config", id) showOutput("VM "+id+" configuration", "qm", "config", id)
case "memory": case "memory":
showMemory("VM "+id+" memory", id) showMemory("VM "+id+" memory", id)
case "unlock": case "unlock":
if confirmAction( if confirmAction("Unlock VM " + id + "?\n\nOnly do this after confirming no task is still running for this VM.") {
"Unlock VM " + id + "?\n\n" +
"Only do this if you have confirmed that no task is still running for this VM.",
) {
showOutput("Unlock VM "+id, "qm", "unlock", id) showOutput("Unlock VM "+id, "qm", "unlock", id)
} }
case "start", "shutdown", "reboot":
runGuestAction("qm", id, choice, "VM")
case "snapshots":
snapshotMenu("qm", id, "VM")
} }
case "back", "": case "back", "":
return return
@@ -383,12 +342,15 @@ func vmMenu() {
func containerMenu() { func containerMenu() {
for { for {
choice := menu( choice := menu(
"Containers (LXC)", "Containers (LXC)", "Choose an action:", 18, 76, 10,
"Choose an action:",
16, 72, 8,
"list", "List containers (pct list)", "list", "List containers (pct list)",
"names", "List container names", "names", "List container names",
"status", "Show container status",
"config", "Show container configuration", "config", "Show container configuration",
"start", "Start a container",
"shutdown", "Gracefully shut down a container",
"reboot", "Reboot a container",
"snapshots", "Container snapshots",
"unlock", "Unlock a container", "unlock", "Unlock a container",
"back", "Back", "back", "Back",
) )
@@ -402,24 +364,77 @@ func containerMenu() {
if requireCommand("pvesh") { if requireCommand("pvesh") {
showResourceNames("Container names", "lxc") showResourceNames("Container names", "lxc")
} }
case "config", "unlock": case "status", "config", "unlock", "start", "shutdown", "reboot", "snapshots":
if !requireCommand("pct") { if !requireCommand("pct") {
continue continue
} }
id, ok := askID("Enter the container ID:") id, ok := askID("Enter the container ID:")
if !ok { if !ok {
continue continue
} }
if choice == "config" { switch choice {
case "status":
showOutput("Container "+id+" status", "pct", "status", id)
case "config":
showOutput("Container "+id+" configuration", "pct", "config", id) showOutput("Container "+id+" configuration", "pct", "config", id)
} else if confirmAction( case "unlock":
"Unlock container " + id + "?\n\n" + if confirmAction("Unlock container " + id + "?\n\nOnly do this after confirming no task is still running for this container.") {
"Only do this if you have confirmed that no task is still running for this container.",
) {
showOutput("Unlock container "+id, "pct", "unlock", id) showOutput("Unlock container "+id, "pct", "unlock", id)
} }
case "start", "shutdown", "reboot":
runGuestAction("pct", id, choice, "container")
case "snapshots":
snapshotMenu("pct", id, "container")
}
case "back", "":
return
}
}
}
func runGuestAction(command, id, action, kind string) {
prompt := fmt.Sprintf("%s %s %s?", action, kind, id)
if action == "shutdown" {
prompt += "\n\nThis requests a graceful shutdown."
}
if !confirmAction(prompt) {
return
}
showOutput(strings.Title(action)+" "+kind+" "+id, command, action, id)
}
func snapshotMenu(command, id, kind string) {
for {
choice := menu(
kind+" snapshots",
"Choose an action:",
12, 72, 5,
"list", "List snapshots",
"create", "Create snapshot",
"delete", "Delete snapshot",
"back", "Back",
)
switch choice {
case "list":
showOutput(kind+" "+id+" snapshots", command, "listsnapshot", id)
case "create":
name, ok := askSnapshotName()
if !ok {
continue
}
if confirmAction("Create snapshot '" + name + "' for " + kind + " " + id + "?\n\nSnapshots are not a substitute for backups and may consume storage.") {
showOutput("Create snapshot "+name, command, "snapshot", id, name)
}
case "delete":
name, ok := askSnapshotName()
if !ok {
continue
}
if confirmAction("Permanently delete snapshot '" + name + "' from " + kind + " " + id + "?\n\nThis cannot be undone.") {
showOutput("Delete snapshot "+name, command, "delsnapshot", id, name)
}
case "back", "": case "back", "":
return return
} }
@@ -430,25 +445,18 @@ func backupMenu() {
if !requireCommand("vzdump") { if !requireCommand("vzdump") {
return return
} }
id, ok := askID("Enter the VM or container ID to back up:") id, ok := askID("Enter the VM or container ID to back up:")
if !ok { if !ok {
return return
} }
if confirmAction("Start a backup for ID " + id + " using vzdump defaults?\n\nReview backup configuration and storage capacity first.") {
if confirmAction(
"Start a backup for ID " + id + " using vzdump defaults?\n\n" +
"Review your node's backup configuration and storage capacity first.",
) {
showOutput("Backup ID "+id, "vzdump", id) showOutput("Backup ID "+id, "vzdump", id)
} }
} }
func restoreMenu() { func restoreMenu() {
choice := menu( choice := menu(
"Restore", "Restore", "Choose what to restore:", 12, 72, 4,
"Choose what to restore:",
12, 72, 4,
"vm", "Restore a QEMU VM (qmrestore)", "vm", "Restore a QEMU VM (qmrestore)",
"ct", "Restore a container (pct restore)", "ct", "Restore a container (pct restore)",
"back", "Back", "back", "Back",
@@ -461,11 +469,7 @@ func restoreMenu() {
if !ok { if !ok {
return return
} }
archive, ok := inputValue("Backup archive", "Enter the full path to the vzdump archive:")
archive, ok := inputValue(
"Backup archive",
"Enter the full path to the vzdump archive:",
)
if !ok { if !ok {
return return
} }
@@ -475,11 +479,7 @@ func restoreMenu() {
message("Archive not found", "That file does not exist or is not a regular file.") message("Archive not found", "That file does not exist or is not a regular file.")
return return
} }
if !confirmAction("Restore '" + archive + "' as ID " + id + "?\n\nConfirm the target ID and archive carefully.") {
if !confirmAction(
"Restore '" + archive + "' as ID " + id + "?\n\n" +
"Restoring can create or overwrite resources. Confirm the target ID and archive carefully.",
) {
return return
} }
@@ -495,9 +495,8 @@ func restoreMenu() {
func storageMenu() { func storageMenu() {
for { for {
choice := menu( choice := menu(
"Storage / LVM", "Storage", "Choose a report:", 14, 72, 6,
"Choose a read-only report:", "status", "Proxmox storage status (pvesm status)",
14, 72, 6,
"pvs", "Physical volumes (pvs)", "pvs", "Physical volumes (pvs)",
"vgs", "Volume groups (vgs)", "vgs", "Volume groups (vgs)",
"lvs", "Logical volumes (lvs)", "lvs", "Logical volumes (lvs)",
@@ -505,6 +504,10 @@ func storageMenu() {
) )
switch choice { switch choice {
case "status":
if requireCommand("pvesm") {
showOutput("Proxmox storage status", "pvesm", "status")
}
case "pvs": case "pvs":
if requireCommand("pvs") { if requireCommand("pvs") {
showOutput("Physical volumes", "pvs") showOutput("Physical volumes", "pvs")
@@ -523,22 +526,90 @@ func storageMenu() {
} }
} }
func resourcesMenu() {
for {
choice := menu(
"Cluster Resources",
"Choose a resource type to display as formatted JSON:",
16, 76, 8,
"vm", "Virtual machines",
"storage", "Storage",
"node", "Nodes",
"sdn", "SDN",
"back", "Back",
)
switch choice {
case "vm", "storage", "node", "sdn":
if requireCommand("pvesh") {
showJSON(
"Cluster "+choice+" resources",
"pvesh",
"get", "/cluster/resources",
"--type", choice,
"--output-format", "json",
)
}
case "back", "":
return
}
}
}
func clusterMenu() {
for {
choice := menu(
"Cluster and Node",
"Choose a report:",
14, 72, 6,
"status", "Cluster status (pvecm status)",
"nodes", "List nodes",
"tasks", "Recent cluster tasks",
"back", "Back",
)
switch choice {
case "status":
if requireCommand("pvecm") {
showOutput("Cluster status", "pvecm", "status")
}
case "nodes":
if requireCommand("pvesh") {
showJSON("Cluster nodes", "pvesh", "get", "/nodes", "--output-format", "json")
}
case "tasks":
if requireCommand("pvesh") {
showJSON(
"Recent cluster tasks",
"pvesh", "get", "/cluster/tasks",
"--limit", "50",
"--output-format", "json",
)
}
case "back", "":
return
}
}
}
func mainMenu() { func mainMenu() {
for { for {
choice := menu( choice := menu(
"Proxmox VE Toolkit "+version, "Proxmox VE Toolkit - DEVELOPMENT ONLY - NOT FOR PRODUCTION "+version,
"Select a task:", "Select a task:",
21, 76, 13, 22, 76, 14,
"vms", "Virtual machines (QEMU)", "vms", "Virtual machines (QEMU)",
"containers", "Containers (LXC)", "containers", "Containers (LXC)",
"backup", "Back up a VM or container (vzdump)", "backup", "Back up a VM or container (vzdump)",
"restore", "Restore a VM or container", "restore", "Restore a VM or container",
"cluster", "Cluster status (pvecm status)", "cluster", "Cluster and node reports",
"version", "Proxmox version (pveversion -v)", "version", "Proxmox version (pveversion -v)",
"storage", "Storage / LVM reports", "storage", "Storage / LVM reports",
"perf", "Host performance test (pveperf)", "perf", "Host performance test (pveperf)",
"subscription", "Subscription status", "subscription", "Subscription status",
"dab", "Debian Appliance Builder help", "dab", "Debian Appliance Builder help",
"resources", "Cluster resources (formatted JSON)",
"quit", "Exit", "quit", "Exit",
) )
@@ -552,9 +623,7 @@ func mainMenu() {
case "restore": case "restore":
restoreMenu() restoreMenu()
case "cluster": case "cluster":
if requireCommand("pvecm") { clusterMenu()
showOutput("Cluster status", "pvecm", "status")
}
case "version": case "version":
if requireCommand("pveversion") { if requireCommand("pveversion") {
showOutput("Proxmox VE version", "pveversion", "-v") showOutput("Proxmox VE version", "pveversion", "-v")
@@ -574,6 +643,8 @@ func mainMenu() {
if requireCommand("dab") { if requireCommand("dab") {
showOutput("Debian Appliance Builder help", "dab", "help") showOutput("Debian Appliance Builder help", "dab", "help")
} }
case "resources":
resourcesMenu()
case "quit", "": case "quit", "":
return return
} }
BIN
View File
Binary file not shown.