023 Project 23: Proxmox Batch Operations CLI
023 Build a Proxmox Batch CLI
Operate many VMs in one command (start/stop/reboot) using Proxmox API token auth and a bounded worker pool. Print a per-VM result summary for automation logs.
parse vmid list -> jobs channel -> workers POST status API -> result lines
Problem statement
pvebatch -node NODE -op start|stop|reboot -ids 101,102,103 [-w 8]
- Auth via
-base/-tokenorPVE_BASE_URL/PVE_TOKEN - Concurrent API calls with worker limit
- Non-zero exit if any VM fails (optional policy)
Acceptance criteria
- Validates required flags
- Worker pool bounds concurrency
- Prints ok/fail per VMID
- HTTP timeout set
- Lab TLS skip documented
Setup
mkdir pvebatch && cd pvebatch
go mod init example.com/pvebatch
# go 1.27
export PVE_BASE_URL='https://pve.example:8006'
export PVE_TOKEN='user@pam!tokenid=secret'Full main.go
package main
import (
"crypto/tls"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
func action(hc *http.Client, base, token, node string, vmid int, op string) error {
url := fmt.Sprintf("%s/api2/json/nodes/%s/qemu/%d/status/%s", base, node, vmid, op)
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "PVEAPIToken="+token)
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func main() {
base := flag.String("base", os.Getenv("PVE_BASE_URL"), "proxmox base url")
token := flag.String("token", os.Getenv("PVE_TOKEN"), "api token")
node := flag.String("node", "pve", "node name")
op := flag.String("op", "start", "start|stop|reboot")
ids := flag.String("ids", "", "comma list vmids")
workers := flag.Int("w", 8, "parallel workers")
insecure := flag.Bool("insecure", true, "skip TLS verify (lab)")
flag.Parse()
switch *op {
case "start", "stop", "reboot":
default:
fmt.Fprintln(os.Stderr, "op must be start|stop|reboot")
os.Exit(2)
}
if *base == "" || *token == "" || *ids == "" {
fmt.Fprintln(os.Stderr, "need -base, -token, -ids (or env PVE_*)")
os.Exit(2)
}
if *workers < 1 {
os.Exit(2)
}
tr := http.DefaultTransport.(*http.Transport).Clone()
if *insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
hc := &http.Client{Timeout: 8 * time.Second, Transport: tr}
jobs := make(chan int)
var wg sync.WaitGroup
var fail atomic.Int64
for i := 0; i < *workers; i++ {
wg.Go(func() {
for id := range jobs {
err := action(hc, *base, *token, *node, id, *op)
if err != nil {
fmt.Printf("vm %d fail: %v\n", id, err)
fail.Add(1)
} else {
fmt.Printf("vm %d ok\n", id)
}
}
})
}
for _, s := range strings.Split(*ids, ",") {
id, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
fmt.Fprintf(os.Stderr, "bad id %q\n", s)
fail.Add(1)
continue
}
jobs <- id
}
close(jobs)
wg.Wait()
if fail.Load() > 0 {
os.Exit(1)
}
}Step-by-step build path
- Flag/env config and op allowlist.
- Shared
http.Clientwith timeout. - Worker pool consuming VMID jobs.
- Atomic fail counter for exit code.
- Document token scopes (VM.PowerMgmt).
Run and verification
go run . -node pve -op start -ids 101,102,103 -w 4
go run . -op stop -ids 101Tests
Unit-test ID parsing logic by extracting a helper:
func parseIDs(s string) ([]int, error) {
var out []int
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
id, err := strconv.Atoi(p)
if err != nil {
return nil, err
}
out = append(out, id)
}
return out, nil
}Stretch goals
- Read VMIDs from file.
- Status poll until guest agent up.
- Dry-run mode printing URLs only.
- JSON summary output.
Pitfalls
| Pitfall | Fix |
|---|---|
| Unbounded goroutines | worker pool |
| No timeout | hung batch |
| Insecure TLS in prod | proper CA pool |
| Ignoring partial failure | exit 1 if any fail |
Learning goals
- Concurrent API automation
- WaitGroup + job channel pools
- Operator-friendly batch tooling