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