035 Project 35: Proxmox Multi-Node Scheduler
035 Build a Proxmox Multi-Node Scheduler
Schedule VM placement across Proxmox nodes by free memory and CPU load. Produce an explainable ranking (dry-run) before you ever call migrate/create APIs.
fetch node stats -> score nodes -> rank placement order -> print plan
Problem statement
CLI that:
- Authenticates with
PVEAPIToken(env or flags) GET /api2/json/nodesfor CPU/memory stats- Scores each node: prefer free memory + CPU headroom
- Prints ranked placement list
- Never mutates the cluster in the base project (dry-run only)
Acceptance criteria
- Fails clearly if env/flags missing
- Deterministic score function (unit-tested)
- Sorted highest score first
- HTTP client timeout + optional TLS skip for lab
- Dry-run only by default
Setup
mkdir pvesched && cd pvesched
go mod init example.com/pvesched
# 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"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"sort"
"time"
)
type NodeStat struct {
Node string `json:"node"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
MaxMem float64 `json:"maxmem"`
Status string `json:"status"`
}
type apiResp struct {
Data []NodeStat `json:"data"`
}
func score(n NodeStat) float64 {
if n.MaxMem <= 0 {
return -1
}
freeMemRatio := 1 - (n.Mem / n.MaxMem)
if freeMemRatio < 0 {
freeMemRatio = 0
}
cpuHeadroom := 1 - n.CPU
if cpuHeadroom < 0 {
cpuHeadroom = 0
}
// weight memory higher for VM placement heuristics
return freeMemRatio*0.7 + cpuHeadroom*0.3
}
func fetchNodes(base, token string, insecure bool) ([]NodeStat, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // lab only
}
hc := &http.Client{Timeout: 8 * time.Second, Transport: tr}
req, err := http.NewRequest(http.MethodGet, base+"/api2/json/nodes", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "PVEAPIToken="+token)
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var out apiResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Data, 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")
insecure := flag.Bool("insecure", true, "skip TLS verify (lab)")
flag.Parse()
if *base == "" || *token == "" {
fmt.Fprintln(os.Stderr, "set -base/-token or PVE_BASE_URL/PVE_TOKEN")
os.Exit(2)
}
nodes, err := fetchNodes(*base, *token, *insecure)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sort.SliceStable(nodes, func(i, j int) bool {
return score(nodes[i]) > score(nodes[j])
})
fmt.Println("placement order (dry-run):")
for i, n := range nodes {
fmt.Printf("%d) node=%s score=%.3f cpu=%.2f mem=%.0f/%.0f status=%s\n",
i+1, n.Node, score(n), n.CPU, n.Mem, n.MaxMem, n.Status)
}
if len(nodes) > 0 {
fmt.Printf("recommended: %s\n", nodes[0].Node)
}
}Run and verification
go run . -base "$PVE_BASE_URL" -token "$PVE_TOKEN"Offline unit test without API:
package main
import "testing"
func TestScorePrefersFreeMem(t *testing.T) {
a := NodeStat{Mem: 10, MaxMem: 100, CPU: 0.5}
b := NodeStat{Mem: 90, MaxMem: 100, CPU: 0.1}
if score(a) <= score(b) {
t.Fatalf("a=%f b=%f", score(a), score(b))
}
}Stretch goals
- Exclude nodes with
status != online. - Factor disk free space into score.
- Emit JSON plan for a later apply step.
- Actually create VM on top-ranked node (dangerous—gate with
-apply).
Pitfalls
| Pitfall | Fix |
|---|---|
| No timeout | hung CLI |
| Trusting lab TLS skip in prod | proper CA |
| Non-explainable ML scores | keep weighted formula printed |
| Mutating by default | dry-run first |
Learning goals
- Explainable scheduling scores
- Proxmox API auth patterns
- Safe automation with dry-run defaults