023 Project 23: Proxmox Batch Operations CLI

Updated

September 8, 2026

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 / -token or PVE_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

  1. Flag/env config and op allowlist.
  2. Shared http.Client with timeout.
  3. Worker pool consuming VMID jobs.
  4. Atomic fail counter for exit code.
  5. 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 101

Tests

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

  1. Read VMIDs from file.
  2. Status poll until guest agent up.
  3. Dry-run mode printing URLs only.
  4. 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