使用账号密码认证时首个请求会触发 PVE 硬编码的 3 秒 401 延迟,可加 WithEagerAuth 在启动时一次性付清;OTP 只能消费一次,会话彻底失效后需用新验证码重建 client;创建 VM 要用 VirtualMachineOption 传原始 qemu 参数名,不能填充 VirtualMachineConfig(那是只读侧)
go-proxmox
luthermonsonApache-2.0活跃维护用 Go 语言封装 Proxmox VE REST API 的客户端库,自带类型定义和测试,方便在 Go 程序里管理虚拟机、LXC 容器和集群。
Go client with types and tests for the Proxmox-VE REST API
go get github.com/luthermonson/go-proxmoxREADME 未说明;这是纯 Go 客户端库,本身不要求 GPU 或显存,只需能访问 Proxmox VE 节点的网络环境
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 单元测试
容易踩的坑
- v0.8.2最新正式版2026-09-30 · 2026-09-30 23:23 UTC
What's Changed
- docs: add vm-template example, document the options-vs-config split by @luthermonson in https://github.com/luthermonson/go-proxmox/pull/345
- fix(vnc): notify consumer and close cleanly on peer disconnect (#344) by @luthermonson in https://github.com/luthermonson/go-proxmox/pull/346
Full Changelog: https://github.com/luthermonson/go-proxmox/compare/v0.8.1...v0.8.2
- v0.8.12026-07-15 · 2026-07-15 02:57 UTC
查看更新说明
Fixes
AgentFileWriteno longer double-encodes content (#343). It base64-encoded the payload and sent PVE'sencode=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 withencode=0so QGA reproduces the original bytes. Binary-safe.
- v0.8.02026-06-16 · 2026-06-16 04:21 UTC
查看更新说明
Breaking changes
VirtualMachine.Deletesignature change — gains a second parameter*VirtualMachineDeleteOptions. Passnilto 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 toVirtualMachine.Delete(#340)VirtualMachine.Pingnow 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
- 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.goagainst PVE'sapidoc.jsonand QGA'sqga/qapi-schema.jsonsurfaced 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.Change Upgrade StorageContent.Encryption(string, tagencryption) →StorageContent.Encrypted(tagencrypted)rename the field reference; old value was always ""because the tag never matched the upstream keyStorageContent.Verificationstring→*StorageContentVerification{State, UPID}dereference and read .State/.UPID; old value was always""because PVE returns a nested objectDeprecate-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.MacAddressdeprecated → readiface.HardwareAddress(the QGA schema has never had a per-IPmac-addressfield). Closes #336.FirewallNodeOption.Ntp/FirewallVirtualMachineOption.Ntpdeprecated → useNDP. The PVE option isndp(Neighbor Discovery Protocol);ntpwas 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 tagCephMonMap.RemovedRanks— sameFirewallRule.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
- 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 (
mtunnelwebsocketbuilders andfile-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 withWithHTTPClientregardless 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)→*Resourcegetter shape that ceph already used in v0.6.0. The full inventory is inAGENTS.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 nowIntOrBoolso they round-trip bothtrue/falseand1/0.proxmox.Ptr(v)is the inline helper for the pointer fields. - Indexed device fields dropped from
VirtualMachineConfig/ContainerConfig. Every numberedNet0..9,SCSI0..30,IDE0..3,SATA0..5,VirtIO0..15,USB0..14,HostPCI0..9,IPConfig0..9,Numa0..9,Parallel0..2,Serial0..3,Unused0..9is gone. The maps populated byUnmarshalJSON(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.CSVtype for PVE fields serialized as comma-joined strings on the wire (most notablySDNZone.Nodesand.Peers).Subdirsmethods on*Clusterand*Nodefor 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.mdfor the complete walkthrough — themed sections with before/after code for each break. The short version:Theme Fix shape Snapshots refactored vm.DeleteSnapshot(ctx, "s")→vm.Snapshot("s").Delete(ctx); same pattern forGet/Update/Rollback/Config.Snapshots(ctx)returns[]*VirtualMachineSnapshot.Firewall rules refactored vm.FirewallGetRule(ctx, 0)→vm.FirewallRule(ctx, 0).vm.FirewallRulesUpdate(ctx, rule)→rule.Update(ctx, opts). Same on*Containerand*Node.Indexed device fields cfg.Net0 = "..."→cfg.Nets["net0"] = "..."(or send viaVirtualMachineOption{Name: "net0", Value: "..."}). AllMerge*helpers g - 100% PVE API coverage — every endpoint in the upstream schema is wrapped, with three documented exceptions for streaming endpoints (
用户点评
登录用户的真实使用记录,可按版本和硬件筛选还没有人点评。第一个说出真实体验的人最有价值。
Proxmox API Client Go Package
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.
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.
- Docker Machine Driver for consumption by Rancher
- Terminal UI inspired by k9s for quick management of PVE Clusters
- Terraform Provider with better local-exec and cloud-init/unattend xml support
- Cluster API Provider Proxmox to create kubernetes clusters
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 insideVirtualMachine.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 reportfalseuntilPinghas 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():
| Sentinel | Helper | Matches |
|---|---|---|
ErrNotFound | IsNotFound | 404, 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., ...) |
ErrNotAuthorized | IsNotAuthorized | 401 and 403 |
ErrForbidden | IsForbidden | 403 only |
ErrBadRequest | IsBadRequest | 400, e.g. Parameter verification failed.; details are in APIError.Errors |
ErrRateLimited | IsRateLimited | 429; APIError.RetryAfter holds the Retry-After hint |
ErrNodeUnreachable | IsNodeUnreachable | 595 / 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.
migration/v0.6.0.md— upgrading from v0.5.xmigration/v0.7.0.md— upgrading from v0.6.0 (the major cleanup release)migration/v0.9.0.md— task waits report failed tasks as errors; typed API errors (*APIError); non-2xx responses that used to be ignored now return errors
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