035 Project 35: Proxmox Multi-Node Scheduler

Updated

September 8, 2026

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/nodes for 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

  1. Exclude nodes with status != online.
  2. Factor disk free space into score.
  3. Emit JSON plan for a later apply step.
  4. 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