Added versioning - 0.1.2

This commit is contained in:
phil committed 2026-10-29 22:14:05 +00:00
1 parent 73e60e5797
commit 5550771ab1
3 files changed
+215 -87

No files matched your search

+203 -86
View File
@@ -2,10 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
@@ -13,7 +15,7 @@ var (
tmpDir string tmpDir string
numeric = regexp.MustCompile(`^[0-9]+$`) numeric = regexp.MustCompile(`^[0-9]+$`)
) )
const version = "V0.1.2"
func main() { func main() {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Fprintf(os.Stderr, "Run this program as root, for example: sudo %s\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Run this program as root, for example: sudo %s\n", os.Args[0])
@@ -36,21 +38,8 @@ func main() {
mainMenu() mainMenu()
} }
// menu displays a whiptail menu and returns the selected tag. // whiptail runs a dialog and captures its selected value through file
// An empty selection means the user cancelled or pressed Esc. // descriptor 3. The dialog itself uses the terminal.
func menu(title, prompt string, height, width, menuHeight int, items ...string) string {
args := []string{
"--title", title,
"--menu", prompt,
fmt.Sprint(height), fmt.Sprint(width), fmt.Sprint(menuHeight),
}
args = append(args, items...)
return whiptail(args...)
}
// dialog runs whiptail and captures its result through file descriptor 3.
// The menu itself continues to use 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 {
@@ -66,7 +55,7 @@ func whiptail(args ...string) string {
cmd.ExtraFiles = []*os.File{writeEnd} cmd.ExtraFiles = []*os.File{writeEnd}
err = cmd.Run() err = cmd.Run()
writeEnd.Close() _ = writeEnd.Close()
var result bytes.Buffer var result bytes.Buffer
_, _ = result.ReadFrom(readEnd) _, _ = result.ReadFrom(readEnd)
@@ -76,16 +65,22 @@ func whiptail(args ...string) string {
return strings.TrimSpace(result.String()) return strings.TrimSpace(result.String())
} }
func menu(title, prompt string, height, width, menuHeight int, items ...string) string {
args := []string{
"--title", title,
"--menu", prompt,
strconv.Itoa(height),
strconv.Itoa(width),
strconv.Itoa(menuHeight),
}
args = append(args, items...)
return whiptail(args...)
}
func message(title, text string) { func message(title, text string) {
whiptail("--title", title, "--msgbox", text, "8", "70") whiptail("--title", title, "--msgbox", text, "8", "70")
} }
func confirm(text string) bool {
return whiptail("--title", "Confirm", "--yesno", text, "10", "70") == ""
}
// Yes/no dialogs also return an empty string on success, so use this
// separate helper to distinguish Yes from No or Esc by the process status.
func confirmAction(text string) bool { func confirmAction(text string) bool {
readEnd, writeEnd, err := os.Pipe() readEnd, writeEnd, err := os.Pipe()
if err != nil { if err != nil {
@@ -94,7 +89,10 @@ func confirmAction(text string) bool {
defer readEnd.Close() defer readEnd.Close()
cmd := exec.Command( cmd := exec.Command(
"whiptail", "--title", "Confirm", "--yesno", text, "10", "70", "whiptail",
"--title", "Confirm",
"--yesno", text,
"10", "70",
) )
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@@ -102,18 +100,10 @@ func confirmAction(text string) bool {
cmd.ExtraFiles = []*os.File{writeEnd} cmd.ExtraFiles = []*os.File{writeEnd}
err = cmd.Run() err = cmd.Run()
writeEnd.Close() _ = writeEnd.Close()
_, _ = readEnd.Read(make([]byte, 1))
return err == nil return err == nil
} }
func input(title, prompt string) (string, bool) {
value := whiptail("--title", title, "--inputbox", prompt, "9", "72")
// An empty value is valid for some prompts, so cancellation is
// determined by whiptail itself in inputValue below when needed.
return value, true
}
func inputValue(title, prompt string) (string, bool) { func inputValue(title, prompt string) (string, bool) {
readEnd, writeEnd, err := os.Pipe() readEnd, writeEnd, err := os.Pipe()
if err != nil { if err != nil {
@@ -121,15 +111,20 @@ func inputValue(title, prompt string) (string, bool) {
} }
defer readEnd.Close() defer readEnd.Close()
cmd := exec.Command("whiptail", "--output-fd", "3", cmd := exec.Command(
"--title", title, "--inputbox", prompt, "9", "72") "whiptail",
"--output-fd", "3",
"--title", title,
"--inputbox", prompt,
"9", "72",
)
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} cmd.ExtraFiles = []*os.File{writeEnd}
err = cmd.Run() err = cmd.Run()
writeEnd.Close() _ = writeEnd.Close()
var result bytes.Buffer var result bytes.Buffer
_, _ = result.ReadFrom(readEnd) _, _ = result.ReadFrom(readEnd)
@@ -141,8 +136,10 @@ func inputValue(title, prompt string) (string, bool) {
func requireCommand(name string) bool { func requireCommand(name string) bool {
if _, err := exec.LookPath(name); err != nil { if _, err := exec.LookPath(name); err != nil {
message("Command unavailable", message(
fmt.Sprintf("The command '%s' is not installed or not available on this node.", name)) "Command unavailable",
fmt.Sprintf("The command '%s' is not installed or not available on this node.", name),
)
return false return false
} }
return true return true
@@ -160,8 +157,22 @@ func askID(prompt string) (string, bool) {
return value, true return value, true
} }
func terminalSize(name string, fallback int) int {
out, err := exec.Command("tput", name).Output()
if err != nil {
return fallback
}
value, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil || value <= 0 {
return fallback
}
return value
}
func showOutput(title string, name string, args ...string) { func showOutput(title string, name string, args ...string) {
outputPath := tmpDir + "/output.txt" outputPath := tmpDir + "/output.txt"
output, err := os.Create(outputPath) output, err := os.Create(outputPath)
if err != nil { if err != nil {
message("Error", "Could not create a temporary output file.") message("Error", "Could not create a temporary output file.")
@@ -174,24 +185,113 @@ func showOutput(title string, name string, args ...string) {
runErr := cmd.Run() runErr := cmd.Run()
_ = output.Close() _ = output.Close()
data, _ := os.ReadFile(outputPath)
if len(data) == 0 {
status := 0 status := 0
if runErr != nil { if runErr != nil {
status = 1 status = 1
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
status = exitErr.ExitCode()
} }
data = []byte(fmt.Sprintf("Command completed with exit status %d; no output.\n", status))
} else if runErr != nil {
data = append(data, []byte("\nCommand exited with an error.\n")...)
} }
_ = os.WriteFile(outputPath, data, 0600)
whiptail("--title", title, "--textbox", outputPath, "24", "85") 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(
"--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"
if err := os.WriteFile(outputPath, []byte(text), 0600); err != nil {
message("Error", "Could not write command output to the temporary file.")
return
}
height := terminalSize("lines", 24) - 4
width := terminalSize("cols", 80) - 4
if height < 12 {
height = 12
}
if width < 50 {
width = 50
}
whiptail(
"--title", title,
"--textbox", outputPath,
strconv.Itoa(height),
strconv.Itoa(width),
)
} }
func vmMenu() { func vmMenu() {
for { for {
choice := menu("Virtual Machines (QEMU)", "Choose an action:", 16, 72, 8, choice := menu(
"Virtual Machines (QEMU)",
"Choose an action:",
16, 72, 8,
"list", "List VMs (qm list)", "list", "List VMs (qm list)",
"config", "Show VM configuration", "config", "Show VM configuration",
"memory", "Show VM memory setting", "memory", "Show VM memory setting",
@@ -208,6 +308,7 @@ func vmMenu() {
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
@@ -217,9 +318,12 @@ func vmMenu() {
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", "qm", "config", id, "^memory:") showMemory("VM "+id+" memory", id)
case "unlock": case "unlock":
if confirmAction("Unlock VM " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this VM.") { if confirmAction(
"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)
} }
} }
@@ -229,31 +333,12 @@ func vmMenu() {
} }
} }
func showMemory(title, command string, args ...string) {
output, err := exec.Command(command, args[:len(args)-1]...).CombinedOutput()
if err != nil {
showOutput(title, command, args[:len(args)-1]...)
return
}
var matches []string
for _, line := range strings.Split(string(output), "\n") {
if matched, _ := regexp.MatchString(args[len(args)-1], line); matched {
matches = append(matches, line)
}
}
if len(matches) == 0 {
matches = []string{"No memory setting found."}
}
path := tmpDir + "/output.txt"
_ = os.WriteFile(path, []byte(strings.Join(matches, "\n")+"\n"), 0600)
whiptail("--title", title, "--textbox", path, "24", "85")
}
func containerMenu() { func containerMenu() {
for { for {
choice := menu("Containers (LXC)", "Choose an action:", 14, 72, 6, choice := menu(
"Containers (LXC)",
"Choose an action:",
14, 72, 6,
"list", "List containers (pct list)", "list", "List containers (pct list)",
"config", "Show container configuration", "config", "Show container configuration",
"unlock", "Unlock a container", "unlock", "Unlock a container",
@@ -269,13 +354,18 @@ func containerMenu() {
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" { if choice == "config" {
showOutput("Container "+id+" configuration", "pct", "config", id) showOutput("Container "+id+" configuration", "pct", "config", id)
} else if confirmAction("Unlock container " + id + "?\n\nOnly do this if you have confirmed that no task is still running for this container.") { } else if confirmAction(
"Unlock container " + id + "?\n\n" +
"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 "back", "": case "back", "":
@@ -288,17 +378,25 @@ 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 your node's 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("Restore", "Choose what to restore:", 12, 72, 4, choice := menu(
"Restore",
"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",
@@ -311,17 +409,25 @@ 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
} }
info, err := os.Stat(archive) info, err := os.Stat(archive)
if archive == "" || err != nil || !info.Mode().IsRegular() { if archive == "" || err != nil || !info.Mode().IsRegular() {
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\nRestoring can create or overwrite resources. Confirm 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
} }
@@ -336,7 +442,10 @@ func restoreMenu() {
func storageMenu() { func storageMenu() {
for { for {
choice := menu("Storage / LVM", "Choose a read-only report:", 14, 72, 6, choice := menu(
"Storage / LVM",
"Choose a read-only report:",
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)",
@@ -344,13 +453,17 @@ func storageMenu() {
) )
switch choice { switch choice {
case "pvs", "vgs", "lvs": case "pvs":
if requireCommand(choice) { if requireCommand("pvs") {
showOutput(map[string]string{ showOutput("Physical volumes", "pvs")
"pvs": "Physical volumes", }
"vgs": "Volume groups", case "vgs":
"lvs": "Logical volumes", if requireCommand("vgs") {
}[choice], choice) showOutput("Volume groups", "vgs")
}
case "lvs":
if requireCommand("lvs") {
showOutput("Logical volumes", "lvs")
} }
case "back", "": case "back", "":
return return
@@ -360,7 +473,10 @@ func storageMenu() {
func mainMenu() { func mainMenu() {
for { for {
choice := menu("Proxmox VE Toolkit", "Select a task:", 21, 76, 13, choice := menu(
"Proxmox VE Toolkit "+version,
"Select a task:",
21, 76, 13,
"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)",
@@ -394,7 +510,8 @@ func mainMenu() {
case "storage": case "storage":
storageMenu() storageMenu()
case "perf": case "perf":
if requireCommand("pveperf") && confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") { if requireCommand("pveperf") &&
confirmAction("Run pveperf?\n\nThis can cause temporary disk activity.") {
showOutput("Host performance", "pveperf") showOutput("Host performance", "pveperf")
} }
case "subscription": case "subscription":
+12 -1
View File
@@ -48,6 +48,7 @@ show_output() {
shift shift
local output="$TMPDIR_TOOLKIT/output.txt" local output="$TMPDIR_TOOLKIT/output.txt"
local status=0 local status=0
local lines cols height width
"$@" >"$output" 2>&1 || status=$? "$@" >"$output" 2>&1 || status=$?
@@ -57,7 +58,17 @@ show_output() {
printf '\nCommand exited with status %s.\n' "$status" >>"$output" printf '\nCommand exited with status %s.\n' "$status" >>"$output"
fi fi
whiptail --title "$title" --textbox "$output" 24 85 # Leave room around the dialog; use sensible fallback sizes if tput fails.
lines=$(tput lines 2>/dev/null || printf '24')
cols=$(tput cols 2>/dev/null || printf '80')
height=$((lines - 4))
width=$((cols - 4))
# Keep dimensions within whiptail's practical limits.
(( height < 12 )) && height=12
(( width < 50 )) && width=50
whiptail --title "$title" --textbox "$output" "$height" "$width"
} }
confirm_action() { confirm_action() {
BIN
View File
Binary file not shown.