Accepted
2026-02-01 (retroactive documentation)
The Proxmox VE API is organized hierarchically:
/api2/json/
├── cluster/
│ ├── firewall/
│ ├── ha/
│ ├── acme/
│ ├── sdn/
│ │ ├── zones/
│ │ ├── vnets/
│ │ └── subnets/
│ └── ...
├── nodes/{node}/
│ ├── qemu/{vmid}/
│ ├── lxc/{vmid}/
│ ├── storage/
│ └── ...
├── access/
│ ├── users/
│ ├── groups/
│ └── ...
└── pools/
We need a consistent pattern for:
- Making HTTP requests to these endpoints
- Organizing client code by domain
- Providing type-safe access to API operations
Use a layered domain client architecture with the following structure:
Handles all HTTP communication:
- Authentication (ticket, API token, username/password)
- Request/response serialization
- Retry logic
- Error handling
- TLS configuration
Wraps API client and provides factory methods for domain clients. Client is an
interface implemented by an unexported client struct:
type Client interface {
Cluster() *cluster.Client
Node(nodeName string) *nodes.Client
Access() *access.Client
Pool() *pools.Client
// ...
}
type client struct {
apiClient api.Client
sshClient ssh.Client
tmpDirOverride string
}
func (c *client) Cluster() *cluster.Client { ... }
func (c *client) Node(nodeName string) *nodes.Client { ... }
func (c *client) Access() *access.Client { ... }
func (c *client) Pool() *pools.Client { ... }Each domain client:
- Embeds
api.Client - Implements
ExpandPath()for URL construction - Provides factory methods for sub-domain clients
- Contains CRUD methods for domain-specific operations
Pattern:
// proxmox/cluster/client.go
package cluster
import "github.com/bpg/terraform-provider-proxmox/proxmox/api"
type Client struct {
api.Client
}
// ExpandPath prepends the cluster base path
func (c *Client) ExpandPath(path string) string {
return fmt.Sprintf("cluster/%s", path)
}
// SDNZones returns a client for SDN zone operations
func (c *Client) SDNZones() *zones.Client {
return &zones.Client{Client: c.Client}
}For deeply nested APIs (e.g., SDN zones → vnets → subnets):
// proxmox/cluster/sdn/zones/client.go
package zones
type Client struct {
api.Client
}
func (c *Client) ExpandPath(path string) string {
return fmt.Sprintf("cluster/sdn/zones/%s", path)
}
// VNets returns a client for VNet operations within this zone
func (c *Client) VNets(zoneID string) *vnets.Client {
return &vnets.Client{
Client: c.Client,
ZoneID: zoneID,
}
}- Clear organization matching Proxmox API structure
- Type-safe client access
- Easy to add new API endpoints
- Shared authentication/connection handling
- IDE-friendly discovery via factory methods
- Some boilerplate for new domain clients
- Deep nesting can be verbose:
client.Cluster().SDNZones().VNets(id).Subnets()
- New API endpoint? Add methods to existing domain client if it fits
- New API domain? Create new package in
proxmox/ - Factory methods should return pointer to new client
- Path expansion handles URL construction, callers pass relative paths
- Response types defined in same package as client
PVE endpoints are form-encoded on write and JSON on read, and both directions have wire quirks that the struct tags must encode.
Field deletion. Request bodies that support clearing fields carry:
Delete []string `url:"delete,omitempty,comma"`Use []string (not *string with manual strings.Join). The comma modifier makes go-querystring serialize as a single delete=field1,field2 parameter, which is what PVE expects — without it, []string serializes as repeated delete=field1&delete=field2 parameters, which is wrong for most PVE endpoints.
Booleans. Use *proxmoxtypes.CustomBool with the ,int modifier on the url: tag:
Disable *types.CustomBool `json:"disable,omitempty" url:"disable,omitempty,int"`CustomBool marshals JSON as "0"/"1"; the ,int modifier makes the URL form encoding emit 0/1. Without it, form encoding emits true/false, which PVE rejects for bool-as-int fields.
Integers PVE serializes as strings. The API viewer documents many fields as integer, but the Perl backend returns some of them as JSON strings when explicitly set (e.g. "15" instead of 15), which crashes a plain *int decode. Type the GET response field *proxmoxtypes.CustomInt (or *CustomInt64) — its UnmarshalJSON accepts both quoted and bare forms. The request field can stay *int: it is only marshaled, never decoded, so the asymmetric narrow fix is correct. Verify which fields are affected with pvesh get or mitmproxy before widening the change.
To add support for a new Proxmox API at /api2/json/cluster/foo/bar:
- Create
proxmox/cluster/foo/client.go:
package foo
import "github.com/bpg/terraform-provider-proxmox/proxmox/api"
type Client struct {
api.Client
}
func (c *Client) ExpandPath(path string) string {
return fmt.Sprintf("cluster/foo/%s", path)
}
type Bar struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Domain client errors use lowercase fmt.Errorf with context.
// The "Unable to [Action] [Resource]" format (ADR-005) is for
// resource-layer Terraform diagnostics only, not domain clients.
func (c *Client) GetBar(ctx context.Context, id string) (*Bar, error) {
var result Bar
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(id), nil, &result)
if err != nil {
return nil, fmt.Errorf("error getting bar with id %s: %w", id, err)
}
return &result, nil
}- Add factory method to
proxmox/cluster/client.go:
func (c *Client) Foo() *foo.Client {
return &foo.Client{Client: c.Client}
}- Calling the base HTTP API directly from resource code instead of through domain clients.
- Hardcoding API paths instead of using
ExpandPath(). - Forgetting to wrap errors with
%w— breakserrors.Is()checks in the resource layer. - Omitting the
commamodifier on aDelete []stringfield — serializes as repeateddelete=parameters instead of one comma-separated value. See Request/Response Body Conventions. - Omitting the
,intmodifier on a*CustomBoolurl:tag — form encoding emitstrue/false, which PVE rejects. - Using plain
*inton a GET response field that PVE serializes as a quoted string — decode crashes at runtime; use*proxmoxtypes.CustomInt.
proxmox/api/client.go— Base HTTP client implementationproxmox/cluster/client.go— Example domain clientproxmox/nodes/client.go— Example with node parameter- ADR-003: Resource File Organization — corresponding
fwprovider/structure - ADR-005: Error Handling — error patterns in domain clients