go-proxmox

luthermonsonApache-2.0活跃维护

用 Go 语言封装 Proxmox VE REST API 的客户端库,自带类型定义和测试,方便在 Go 程序里管理虚拟机、LXC 容器和集群。

Go client with types and tests for the Proxmox-VE REST API

v0.8.2发布于 2026-09-30 23:23 UTC294star +1 / 7 天84fork同步于 2026-11-11 05:57 UTC

怎么上手

由 README 提炼,执行前请确认
go get github.com/luthermonson/go-proxmox
硬件要求按 README 说明

README 未说明;这是纯 Go 客户端库,本身不要求 GPU 或显存,只需能访问 Proxmox VE 节点的网络环境

下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

由 README 自动翻译提炼

Go 语言的 Proxmox VE REST API 客户端,提供完整类型定义和测试,省去手写 HTTP 调用

用 Go 语言封装 Proxmox VE REST API 的客户端库,自带类型定义和测试,方便在 Go 程序里管理虚拟机、LXC 容器和集群。

核心能力

  • 封装 PVE 8.x/9.x 全部 /api2/json 端点,每个端点都有类型化 Go 包装
  • 用 VirtualMachineOption 键值对创建和修改 VM,字段名与 qm create 一致
  • 提供 NodeRef/VMRef 句柄,无需预取即可直接执行 Start/Stop 等操作
  • 支持 API Token、账号密码、OTP 双因素和 mTLS 多种认证方式
  • 内置 Client 日志、Context 支持与 mock 单元测试

容易踩的坑

使用账号密码认证时首个请求会触发 PVE 硬编码的 3 秒 401 延迟,可加 WithEagerAuth 在启动时一次性付清;OTP 只能消费一次,会话彻底失效后需用新验证码重建 client;创建 VM 要用 VirtualMachineOption 传原始 qemu 参数名,不能填充 VirtualMachineConfig(那是只读侧)

内容由模型根据 README 生成,可能不完整或不准确,以官方文档为准。看原文 README

最近发版

5 个版本v0.8.2
  1. v0.8.2最新正式版2026-09-30 · 2026-09-30 23:23 UTC

    What's Changed

    Full Changelog: https://github.com/luthermonson/go-proxmox/compare/v0.8.1...v0.8.2

  2. v0.8.12026-07-15 · 2026-07-15 02:57 UTC
    查看更新说明

    Fixes

    • AgentFileWrite no longer double-encodes content (#343). It base64-encoded the payload and sent PVE's encode=1, so PVE encoded it a second time and QGA's single decode left base64 text on disk — corrupting any file written this way (configs, keys). Now sends the base64 payload with encode=0 so QGA reproduces the original bytes. Binary-safe.
  3. v0.8.02026-06-16 · 2026-06-16 04:21 UTC
    查看更新说明

    Breaking changes

    VirtualMachine.Delete signature change — gains a second parameter *VirtualMachineDeleteOptions. Pass nil to preserve existing behaviour. See migration/v0.8.0.md for details and before/after examples.

    // before
    task, err := vm.Delete(ctx)
    
    // after
    task, err := vm.Delete(ctx, nil)                       // same behaviour
    task, err := vm.Delete(ctx, &proxmox.VirtualMachineDeleteOptions{
        Purge: true,
        DestroyUnreferencedDisks: true,
    })
    

    What's new

    • VirtualMachineDeleteOptions — pass purge, skiplock, destroy-unreferenced-disks to VirtualMachine.Delete (#340)
    • VirtualMachine.Ping now correctly propagates config-fetch errors (#339)
    • golangci-lint config expanded: gofmt enforced, 10 additional linters enabled, minor findings fixed (TLS MinVersion, websocket body close, errors.Is) (#341)

    Migration guide

    See migration/v0.8.0.md.

    Full changelog: https://github.com/luthermonson/go-proxmox/compare/v0.7.1...v0.8.0

  4. v0.7.12026-06-02 · 2026-06-02 18:20 UTC
    查看更新说明

    Highlights

    v0.7.1 is a correctness patch. A full audit of every exported response type in types.go against PVE's apidoc.json and QGA's qga/qapi-schema.json surfaced eight long-standing schema mismatches — six of them silently no-op'd on the wire (wrong JSON tag, wrong scalar type, or a field name PVE never emitted). The apparent breaks below are functionally additive: the new field is the first one that has ever worked.

    ⚠️ Breaking changes & migration

    Two source-level breaks, both on StorageContent. See migration/v0.7.1.md for the full upgrade walkthrough.

    ChangeUpgrade
    StorageContent.Encryption (string, tag encryption) → StorageContent.Encrypted (tag encrypted)rename the field reference; old value was always "" because the tag never matched the upstream key
    StorageContent.Verification string → *StorageContentVerification{State, UPID}dereference and read .State / .UPID; old value was always "" because PVE returns a nested object

    Deprecate-and-replace (no compile break)

    The Go field still exists and code still compiles; only the wire format changed. Switch to the replacement to get real behavior.

    • AgentNetworkIPAddress.MacAddress deprecated → read iface.HardwareAddress (the QGA schema has never had a per-IP mac-address field). Closes #336.
    • FirewallNodeOption.Ntp / FirewallVirtualMachineOption.Ntp deprecated → use NDP. The PVE option is ndp (Neighbor Discovery Protocol); ntp was a typo shipped since v0.1.x and never had any effect.

    Internal-only JSON tag fixes (no caller impact)

    Tags fixed so the fields finally unmarshal from PVE:

    • CephMonMap.DisallowedLeaders — strip trailing ": " inside the quoted tag
    • CephMonMap.RemovedRanks — same
    • FirewallRule.IcmpType — icmp_type → icmp-type (ICMP rules now round-trip correctly)

    What didn't change

    • Wire format for everything not listed above
    • NewClient(...) with no options still behaves identically to v0.7.0
    • All v0.7.0 client options (WithProxy, WithRetry, WithEagerAuth, …) unchanged
    • v0.7.0 instance-handle patterns (snapshots, firewall rules, SDN, etc.) unchanged

    What's Changed

    See https://github.com/luthermonson/go-proxmox/compare/v0.7.0...v0.7.1 once tagged.

    Full Changelog: https://github.com/luthermonson/go-proxmox/compare/v0.7.0...v0.7.1

  5. v0.7.02026-05-31 · 2026-05-31 02:10 UTC
    查看更新说明

    Highlights

    v0.7.0 is the major cleanup release. Three years of accumulated patterns get resolved into a consistent shape:

    • 100% PVE API coverage — every endpoint in the upstream schema is wrapped, with three documented exceptions for streaming endpoints (mtunnelwebsocket builders and file-restore/download). The v0.6.0 release sat at 81%; the v0.7.x cycle closed the cluster gap entirely with the SDN sweep (#320), the bulk-action / ceph-flags / qemu-cpu-models / options / log / notifications / HA arm-disarm cleanup (#322), and the rest.
    • Twelve new client options for transport, auth, and middleware: WithTimeout, WithInsecureSkipVerify, WithRootCAs, WithRootCAFile, WithClientCertificate, WithOTP, WithDefaultRealm, WithEagerAuth, WithProxy, WithProxyFromEnvironment, WithRetry (+ helpers), WithRequestInterceptor. All compose with WithHTTPClient regardless of option order. README has lab + production usage examples.
    • Consistent instance-handle pattern across the library. VM/container snapshots, firewall rules, and the new SDN resources (controllers, DNS, fabrics, IPAMs, prefix-lists, route-maps) all follow the parent.Resource(id) → *Resource getter shape that ceph already used in v0.6.0. The full inventory is in AGENTS.md.
    • Config-struct defaults stop biting. Issue #199 is closed. ~30 fields where the Go zero value would silently override a PVE server default are now *T; ~25 bool-on-wire fields are now IntOrBool so they round-trip both true/false and 1/0. proxmox.Ptr(v) is the inline helper for the pointer fields.
    • Indexed device fields dropped from VirtualMachineConfig / ContainerConfig. Every numbered Net0..9, SCSI0..30, IDE0..3, SATA0..5, VirtIO0..15, USB0..14, HostPCI0..9, IPConfig0..9, Numa0..9, Parallel0..2, Serial0..3, Unused0..9 is gone. The maps populated by UnmarshalJSON (Nets, SCSIs, IPConfigs, …) are now the authoritative shape. PVE actually returns more indices than the explicit fields ever covered (net0..net31, scsi0..scsi30, unused0..unused255) — the old shape was silently dropping data.
    • proxmox.CSV type for PVE fields serialized as comma-joined strings on the wire (most notably SDNZone.Nodes and .Peers).
    • Subdirs methods on *Cluster and *Node for ACL-filtered directory-index probes. Use them to discover what an API token actually has permission to read without try-and-403 against every endpoint.
    • Unit-test coverage pushed from ~30% to ~88%+. The remaining gap is websocket dialers, file-restore streaming, and time-loop poller bodies — all better covered by integration tests.

    ⚠️ Breaking changes & migration

    This is a heavy release for source compatibility. The breaks are concentrated in three areas: the snapshot/firewall instance-pattern refactors, the dropped indexed device fields, and the config-struct pointerization sweep. Most fixes are mechanical.

    Read migration/v0.7.0.md for the complete walkthrough — themed sections with before/after code for each break. The short version:

    ThemeFix shape
    Snapshots refactoredvm.DeleteSnapshot(ctx, "s") → vm.Snapshot("s").Delete(ctx); same pattern for Get/Update/Rollback/Config. Snapshots(ctx) returns []*VirtualMachineSnapshot.
    Firewall rules refactoredvm.FirewallGetRule(ctx, 0) → vm.FirewallRule(ctx, 0). vm.FirewallRulesUpdate(ctx, rule) → rule.Update(ctx, opts). Same on *Container and *Node.
    Indexed device fieldscfg.Net0 = "..." → cfg.Nets["net0"] = "..." (or send via VirtualMachineOption{Name: "net0", Value: "..."}). All Merge* helpers g

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    README 原文

    未翻译

    Proxmox API Client Go Package

    Continuous Integration GitHub license GitHub issues GitHub release codecov Go Report Card Go Reference

    Join the community to discuss ongoing client development usage, the proxmox API or tooling in the #go-proxmox channel on the Gophers Slack and see the self generated docs for more usage details.

    Slack

    A go client for Proxmox VE. The client implements /api2/json and inspiration was drawn from the existing Telmate package but looking to improve in the following ways...

    • Treated as a proper standalone go package
    • Types and JSON marshal/unmarshalling for all end points
    • Full Testing, unit testing with mocks and integration tests against an API endpoint
    • Configuration options when creating a client for flexible usage
    • Client logging for debugging within your code
    • Context support
    • Added functionality for better go tooling built on this library, some things we'd like
      • Boot VM from qcow URL, inspiration: Proxmox Linux Templates
      • Dynamic host targeting for VM, Proxmox lacks a scheduler when given VM params it will try and locate a host with resources to put it
      • cloud-init support via no-cloud ISOs uploaded to node data stores and auto-mounted before boot, inspiration quiso
      • Unattended XML Support via ISOs similar to cloud-init ideas
      • node/vm/container shell command support via KVM proxy already built into proxmox

    API coverage

    go-proxmox wraps 100% of the upstream PVE /api2/json surface for PVE 8.x and 9.x — every endpoint in the API viewer has a typed Go wrapper, with three intentional exceptions documented in mage/endpoints/endpoints.go: the two mtunnelwebsocket URL builders (the library returns the signed URL via MigrationTunnelWebSocketPath; the caller plumbs into their own websocket dialer) and the file-restore/download streaming binary endpoint.

    Coverage is tracked in CI via mage endpoints:coverage, which diffs the live PVE schema against the package's call sites. Run it locally to confirm a fresh schema bump didn't add anything new:

    mage endpoints:sync       # refresh .cache/pve-api/endpoints.json from upstream
    mage endpoints:coverage   # print per-area coverage; lists any missing endpoints
    

    Core developers are home lab enthusiasts working in the virtualization and kubernetes space. The common use case we have for Proxmox is dev stress testing and validation of functionality in the products we work on, we plan to build the following tooling around this library to make that easier.

    Usage

    Create a client and use the public methods to access Proxmox resources.

    Basic usage with login with a username and password credential

    package main
    
    import (
    	"context"
    	"fmt"
    	
    	"github.com/luthermonson/go-proxmox"
    )
    
    func main() {
        credentials := proxmox.Credentials{
    		Username: "root@pam", 
    		Password: "12345",
        }
        client := proxmox.NewClient("https://localhost:8006/api2/json",
    		proxmox.WithCredentials(&credentials),
        )
    	
        version, err := client.Version(context.Background())
        if err != nil {
            panic(err)
        }
        fmt.Println(version.Release) // 7.4
    }
    

    Usage with Client Options

    Lab setup (self-signed PVE, short timeout, API token):

    package main
    
    import (
    	"context"
    	"fmt"
    	"time"
    
    	"github.com/luthermonson/go-proxmox"
    )
    
    func main() {
    	client := proxmox.NewClient("https://localhost:8006/api2/json",
    		proxmox.WithAPIToken("root@pam!mytoken", "somegeneratedapitokenguidefromtheproxmoxui"),
    		proxmox.WithInsecureSkipVerify(),     // lab only
    		proxmox.WithTimeout(30*time.Second),  // http.DefaultClient has no timeout
    	)
    
    	version, err := client.Version(context.Background())
    	if err != nil {
    		panic(err)
    	}
    	fmt.Println(version.Release) // 6.3
    }
    

    Production setup (pinned CA, mTLS optional):

    caBundle, _ := os.ReadFile("/etc/ssl/certs/pve-cluster.pem")
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caBundle)
    
    client := proxmox.NewClient("https://pve.example.com:8006/api2/json",
    	proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
    	proxmox.WithRootCAs(pool),
    	proxmox.WithTimeout(30*time.Second),
    	// optional mTLS:
    	// proxmox.WithClientCertificate(clientCert),
    )
    

    Credential authentication and 2FA

    API tokens are the right choice for daemons and CI. For interactive tools — anything with a human typing a password and a TOTP code — use credentials with WithOTP and WithEagerAuth:

    client := proxmox.NewClient(url,
    	proxmox.WithCredentials(&proxmox.Credentials{
    		Username: "admin",
    		Password: password,
    	}),
    	proxmox.WithDefaultRealm("pam"),  // "admin" gets Realm "pam" merged in
    	proxmox.WithOTP("123456"),        // one-shot; consumed on first /access/ticket
    	proxmox.WithEagerAuth(),          // see note below
    )
    

    WithEagerAuth is worth calling out. PVE's pveproxy enforces a hardcoded 3-second delay on every 401 response as a brute-force mitigation (see PVE::APIServer::AnyEvent's # always delay unauthorized calls by 3 seconds block). With credential auth the library's first request goes out unauthenticated by design — the ticket isn't issued until /access/ticket succeeds — so the first user-facing call eats the full 3 seconds. WithEagerAuth runs CreateSession inside NewClient so that cost is paid once at startup and every subsequent request is a normal ticket-authenticated call. Token auth doesn't trigger the 401 path at all and doesn't need this.

    The OTP is consumed exactly once; subsequent RefreshTicket calls renew the session via the ticket itself and don't need a new code. If the session is fully lost later (PVE restart invalidates tickets), construct a fresh client with a fresh OTP — TOTP codes can't be cached.

    Resource traversal: instance handles

    Most resources identified by an id/name follow a getter-returns-handle pattern. The handle carries the parent's client and identifying fields, so callers don't re-thread (node, id) on every call.

    cluster, _ := client.Cluster(ctx)
    
    // SDN controllers — getter on the parent, operations on the instance.
    ctrl := cluster.SDNController("evpn-1")
    if err := ctrl.Update(ctx, &proxmox.SDNControllerOptions{ASN: proxmox.IntOrBool(65000)}); err != nil {
        panic(err)
    }
    _, _ = ctrl.Delete(ctx)
    
    // Same pattern for VM/container snapshots, firewall rules, ceph OSDs/pools/mons,
    // HA resources, ACME accounts, custom CPU models, fabrics, IPAMs, prefix-lists, etc.
    

    See AGENTS.md (Required: pick the right shape for new endpoints) for the full inventory of instance types.

    Acting on a node, VM or container without fetching it first

    client.Node and node.VirtualMachine fetch before they return: the node's status, then the VM's status and config. That is three GETs before a vm.Stop. When you already know where the VM lives and only want to act on it, build a ref instead. A ref makes no request until you call a method:

    // Fetch: 3 GETs, then the POST. Use it when you're going to read the VM.
    node, err := client.Node(ctx, "pve1")
    vm, err := node.VirtualMachine(ctx, 100)
    fmt.Println(vm.Status, vm.VirtualMachineConfig.Memory)
    
    // Ref: no requests to build it, so Stop is the POST alone.
    ref := client.NodeRef("pve1").VirtualMachineRef(100)
    task, err := ref.Stop(ctx)
    
    // A ref has only the node and VMID. Ping fills its status and config.
    err = ref.Ping(ctx)
    fmt.Println(ref.IsRunning())
    
    // LXC is the same.
    ct := client.NodeRef("pve1").ContainerRef(101)
    task, err = ct.Start(ctx)
    

    Refs return the ordinary *Node, *VirtualMachine and *Container types, so every method is available:

    • Actions (Start, Stop, Shutdown, Reboot, Migrate, Config, Clone, snapshots, firewall, agent, ...) need only the node and VMID, and send exactly the one request they always did.
    • Config read-modify-write (AddTag, RemoveTag, CloudInit, UnmountCloudInitISO, and the cloud-init ISO cleanup inside VirtualMachine.Delete) fetch the config themselves when the handle doesn't have it, so existing tags are kept rather than overwritten.
    • Accessors (IsRunning, IsStopped, IsPaused, IsHibernated, HasTag) take no context and can't fetch. On a ref they report false until Ping has run.

    The node and VMID aren't checked when the ref is built; a VM that doesn't exist fails on the first real call. Refresh a node ref with node.Status(ctx). See examples/vm-refs for a runnable version.

    Create a VM and build a template

    Creating or editing a VM goes through VirtualMachineOption key/value pairs, whose names are the raw qemu parameter names from the PVE API viewer — the same names qm create takes. VirtualMachineConfig is the read side: GET /config unmarshals into it (that's why its fields use tolerant types like StringOrInt — PVE returns the same field as a number or a quoted string depending on version), and the library never sends it to the API. Don't populate it to create a VM.

    node, err := client.Node(ctx, "pve1")
    if err != nil {
        panic(err)
    }
    
    // Ask the cluster for the next free VMID instead of inventing one.
    cluster, err := client.Cluster(ctx)
    if err != nil {
        panic(err)
    }
    vmid, err := cluster.NextID(ctx)
    if err != nil {
        panic(err)
    }
    
    task, err := node.NewVirtualMachine(ctx, vmid,
        proxmox.VirtualMachineOption{Name: "name", Value: "ubuntu-template"},
        proxmox.VirtualMachineOption{Name: "memory", Value: 2048},
        proxmox.VirtualMachineOption{Name: "cores", Value: 2},
        proxmox.VirtualMachineOption{Name: "scsihw", Value: "virtio-scsi-single"},
        proxmox.VirtualMachineOption{Name: "scsi0", Value: "local-lvm:0,import-from=local:import/noble-server-cloudimg-amd64.qcow2"},
        proxmox.VirtualMachineOption{Name: "ide2", Value: "local-lvm:cloudinit"},
        proxmox.VirtualMachineOption{Name: "net0", Value: "virtio,bridge=vmbr0"},
        proxmox.VirtualMachineOption{Name: "boot", Value: "order=scsi0"},
    )
    if err != nil {
        panic(err)
    }
    if err := task.Wait(ctx, time.Second, 5*time.Minute); err != nil {
        panic(err)
    }
    
    // The create task itself carries the new VMID: task.ID is the UPID's id
    // field, which for a qmcreate task is the VMID. Useful when the task is all
    // you're holding.
    vmid, err = strconv.Atoi(task.ID)
    if err != nil {
        panic(err)
    }
    
    vm, err := node.VirtualMachine(ctx, vmid)
    if err != nil {
        panic(err)
    }
    
    // Post-create edits use the same options: Config is an async POST returning
    // a *Task, ConfigSync is a synchronous PUT.
    if err := vm.ConfigSync(ctx,
        proxmox.VirtualMachineOption{Name: "ciuser", Value: "ubuntu"},
        proxmox.VirtualMachineOption{Name: "ipconfig0", Value: "ip=dhcp"},
        // sshkeys needs PVE's exact urlencoding — see EncodeSSHKeys.
        proxmox.VirtualMachineOption{Name: "sshkeys", Value: proxmox.EncodeSSHKeys(pubkey)},
    ); err != nil {
        panic(err)
    }
    
    task, err = vm.ConvertToTemplate(ctx)
    if err != nil {
        panic(err)
    }
    if err := task.Wait(ctx, time.Second, 2*time.Minute); err != nil {
        panic(err)
    }
    

    The full runnable flow — cloud-image download via Storage.DownloadURL, disk import, cloud-init, resize, template, clone — lives in examples/vm-template.

    Permission / capability discovery via Subdirs

    PVE's directory-index GETs are ACL-filtered: the response only lists the sub-resources the calling token is permitted to read. Use them to probe what an API token can do without try-and-403 against every endpoint:

    node, _ := client.Node(ctx, "pve1")
    subdirs, _ := node.Subdirs(ctx)         // ["qemu", "lxc", "storage", ...] filtered by ACL
    fw, _ := node.FirewallSubdirs(ctx)      // ["rules", "options", "log"] if reachable
    
    cluster, _ := client.Cluster(ctx)
    sdnAreas, _ := cluster.SDNSubdirs(ctx)  // ["vnets", "zones", "controllers", ...]
    

    CSV type for comma-joined PVE fields

    A few PVE fields serialize as comma-joined strings on the wire (most notably SDNZone.Nodes and .Peers, plus various group/realm fields). proxmox.CSV is a typed []string whose UnmarshalJSON accepts both "a,b,c" and ["a","b","c"], and whose MarshalJSON always emits the comma-joined form PVE expects:

    // Read side: PVE returns `"nodes": "pve1,pve2,pve3"`. CSV presents it as a slice.
    zone, _ := cluster.SDNZone(ctx, "vxlan-1")
    for _, n := range zone.Nodes {                  // proxmox.CSV — iterable like []string
        fmt.Println(n)
    }
    allNodes := []string(zone.Nodes)                // explicit cast when you need []string
    
    // Write side: the matching *Options types take a plain comma-joined string,
    // because PVE only accepts that form on POST/PUT.
    _ = cluster.NewSDNZone(ctx, &proxmox.SDNZoneOptions{
        Name:  "vxlan-1",
        Type:  "vxlan",
        Nodes: "pve1,pve2,pve3",
    })
    

    Proxies

    Route every request through a forward proxy. Works for http://, https://, and socks5:// URLs:

    proxyURL, _ := url.Parse("http://proxy.corp.example.com:3128")
    client := proxmox.NewClient("https://pve.example.com:8006/api2/json",
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithProxy(proxyURL),
    )
    

    Or use the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY env-var convention:

    client := proxmox.NewClient("https://pve.example.com:8006/api2/json",
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithProxyFromEnvironment(),
    )
    

    WithProxyFromEnvironment reads env vars per-request (via Go's standard http.ProxyFromEnvironment), so changes after NewClient take effect on the next call. Composes with WithHTTPClient, the TLS options, retry, and request interceptors — option order doesn't matter.

    Retries on transient failures

    PVE returns 502 / 503 during cluster transitions and 429 when rate-limited. WithRetry installs a RoundTripper wrapper that retries with full-jitter exponential backoff, honors Retry-After on 429 / 503, and respects request-context cancellation:

    client := proxmox.NewClient(url,
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithRetry(),  // defaults: 3 attempts, 200ms–5s backoff
    )
    

    Tune the defaults for flakier upstreams:

    client := proxmox.NewClient(url,
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithRetry(
            proxmox.WithRetryMax(5),
            proxmox.WithRetryBackoff(500*time.Millisecond, 30*time.Second),
        ),
    )
    

    Or replace the predicate that decides what to retry — for example to also retry 423 Locked while a cluster transition is in flight:

    retryOn423 := func(res *http.Response, err error) bool {
        if err != nil {
            return true
        }
        switch res.StatusCode {
        case http.StatusLocked, http.StatusBadGateway,
            http.StatusServiceUnavailable, http.StatusGatewayTimeout,
            http.StatusTooManyRequests:
            return true
        }
        return false
    }
    client := proxmox.NewClient(url,
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithRetry(proxmox.WithRetryCondition(retryOn423)),
    )
    

    The default predicate retries network errors plus 502 / 503 / 504 / 429. Only idempotent verbs (GET, PUT, DELETE) and POST with a fully-buffered body are eligible — this client always buffers request bodies as []byte, so POST is rewindable in practice.

    Request interceptors

    Run a function on every outgoing request after the auth headers are populated and before the request is sent. Useful for tracing, correlation IDs, custom audit headers, request logging:

    addCorrelationID := func(req *http.Request) error {
        req.Header.Set("X-Correlation-Id", "build-1234")
        return nil
    }
    client := proxmox.NewClient(url,
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithRequestInterceptor(addCorrelationID),
    )
    

    Multiple interceptors compose — each WithRequestInterceptor call appends to the chain, and they run in registration order. The first non-nil error short-circuits the request (with a request interceptor: prefix so callers can errors.Is against their own sentinels):

    tracing := func(req *http.Request) error {
        // pull the active span from req.Context() and inject W3C traceparent.
        req.Header.Set("Traceparent", traceparent.From(req.Context()))
        return nil
    }
    audit := func(req *http.Request) error {
        log.Info().Str("method", req.Method).Str("path", req.URL.Path).Msg("pve")
        return nil
    }
    client := proxmox.NewClient(url,
        proxmox.WithAPIToken("automation@pve!ci", "<secret>"),
        proxmox.WithRequestInterceptor(tracing),
        proxmox.WithRequestInterceptor(audit),
    )
    

    The chain fires from Req, Upload, and UploadReader. Websocket upgrades (TermWebSocket, VNCWebSocket) are exempt — the dialer doesn't surface a *http.Request the chain could mutate.

    Error handling

    Every non-2xx response comes back as a *proxmox.APIError carrying the status code, PVE's message, and the per-parameter errors map from the response body:

    var apiErr *proxmox.APIError
    if errors.As(err, &apiErr) {
        log.Printf("%s %s -> %d: %s %v",
            apiErr.Method, apiErr.Path, apiErr.StatusCode, apiErr.Message, apiErr.Errors)
    }
    

    Classify errors with errors.Is or the matching helper rather than matching on err.Error():

    SentinelHelperMatches
    ErrNotFoundIsNotFound404, plus the PVE 500/400 messages that mean a resource doesn't exist (Configuration file '...' does not exist, storage 'x' does not exist, no such user, no such job, No such storage., ...)
    ErrNotAuthorizedIsNotAuthorized401 and 403
    ErrForbiddenIsForbidden403 only
    ErrBadRequestIsBadRequest400, e.g. Parameter verification failed.; details are in APIError.Errors
    ErrRateLimitedIsRateLimited429; APIError.RetryAfter holds the Retry-After hint
    ErrNodeUnreachableIsNodeUnreachable595 / 596, which pveproxy returns when it can't reach the target cluster node
    vm, err := node.VirtualMachine(ctx, 100)
    switch {
    case proxmox.IsNotFound(err):
        // create it
    case proxmox.IsNodeUnreachable(err):
        // try another node
    case err != nil:
        return err
    }
    

    PVE reports most missing resources as a generic 500 rather than a 404, so ErrNotFound is partly matched on the message. The patterns are deliberately conservative and listed with their upstream sources in errors.go. For anything they don't cover, check APIError.StatusCode and APIError.Message directly.

    Error() keeps the text these errors had before APIError existed: it starts with the HTTP status line (500 QEMU guest agent is not running), 400s keep the bad request: <status> - <errors> form, and 401/403 keep the not authorized to access endpoint prefix. See migration/v0.9.0.md for what changed.

    More examples

    • examples/vm-template — build a qemu template from a cloud image: download, disk import, cloud-init, resize, convert.
    • examples/vm-refs — act on a VM through a zero-request ref, next to the fetch pattern, and when to use which.
    • examples/sdn — full SDN walkthrough: create a zone, vnet, subnet, controller, dry-run / apply / rollback.
    • examples/term-and-vnc — websocket terminal and VNC proxy via a small Gin server.

    Upgrading between releases

    Per-release migration guides live in migration/. Each file describes the source-level changes when upgrading FROM the named release.

    Developing

    This project relies on Mage for cross os/arch compatibility, please see their installation guide.

    Unit Testing

    Run mage test to run the unit tests in the root directory.

    Integration Testing

    To run the integration testing suite against an existing Proxmox API set some env vars in your shell before running mage testIntegration. The integration tests will test logging in and using an API token credentials so make sure you set all five env vars before running tests for them to pass.

    Please leave no trace when developing integration tests. All tests should create and remove all testing data they generate then they can be repeatably run against the same proxmox environment. Most people working on this package will likely use their personal Proxmox VE home lab and consuming extra resources via tests will lead to frustration.

    Bash

    export PROXMOX_URL="https://192.168.1.6:8006/api2/json"
    export PROXMOX_USERNAME="root@pam"
    export PROXMOX_PASSWORD="password"
    export PROXMOX_TOKENID="root@pam!mytoken"
    export PROXMOX_SECRET="somegeneratedapitokenguidefromtheproxmoxui"
    
    mage test:integration
    

    Powershell

    $Env:PROXMOX_URL = "https://192.168.1.6:8006/api2/json"
    $Env:PROXMOX_USERNAME = "root@pam"
    $Env:PROXMOX_PASSWORD = "password"
    $Env:PROXMOX_TOKENID = "root@pam!mytoken"
    $Env:PROXMOX_SECRET = "somegeneratedapitokenguidefromtheproxmoxui"
    
    mage test:integration