feat: add PVE API client with multi-host failover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-20 11:25:48 +00:00
parent b8d69f2589
commit 210e22e030
2 changed files with 227 additions and 0 deletions

109
collector/client.go Normal file
View file

@ -0,0 +1,109 @@
package collector
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
// Client is an HTTP client for the Proxmox VE API.
// It supports multi-host failover: hosts are tried in order, and the last
// successful host is remembered for subsequent requests.
type Client struct {
hosts []string
token string
maxConc int
httpClient *http.Client
lastGoodHost int
mu sync.Mutex
}
// NewClient creates a new PVE API client.
// hosts is a list of base URLs (e.g. "https://pve1.example.com:8006").
// token is the PVE API token in the form "USER@REALM!TOKENID=SECRET".
// tlsInsecure disables TLS certificate verification when true.
// maxConcurrent controls the maximum number of concurrent API requests.
func NewClient(hosts []string, token string, tlsInsecure bool, maxConcurrent int) *Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 1 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: tlsInsecure,
},
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
}
return &Client{
hosts: hosts,
token: token,
maxConc: maxConcurrent,
httpClient: &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
},
}
}
// MaxConcurrent returns the maximum number of concurrent API requests.
func (c *Client) MaxConcurrent() int {
return c.maxConc
}
// Get performs an HTTP GET against the PVE API. The path is prepended with
// /api2/json (e.g. "/cluster/resources" becomes "/api2/json/cluster/resources").
// Hosts are tried in order starting from the last known good host. If a request
// fails or returns a non-2xx status, the next host is tried.
func (c *Client) Get(path string) ([]byte, error) {
c.mu.Lock()
startIdx := c.lastGoodHost
c.mu.Unlock()
fullPath := "/api2/json" + path
var lastErr error
for i := 0; i < len(c.hosts); i++ {
idx := (startIdx + i) % len(c.hosts)
host := c.hosts[idx]
url := host + fullPath
req, err := http.NewRequest("GET", url, nil)
if err != nil {
lastErr = fmt.Errorf("creating request for %s: %w", url, err)
continue
}
req.Header.Set("Authorization", "PVEAPIToken="+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("request to %s: %w", host, err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("reading response from %s: %w", host, err)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
lastErr = fmt.Errorf("request to %s returned status %d", host, resp.StatusCode)
continue
}
c.mu.Lock()
c.lastGoodHost = idx
c.mu.Unlock()
return body, nil
}
return nil, fmt.Errorf("all PVE hosts failed: %w", lastErr)
}

118
collector/client_test.go Normal file
View file

@ -0,0 +1,118 @@
package collector
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientGetAuthHeader(t *testing.T) {
var gotAuth string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
client := NewClient([]string{server.URL}, "user@pam!token=secret-value", false, 5)
client.httpClient = server.Client()
body, err := client.Get("/cluster/resources")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"data":[]}` {
t.Errorf("unexpected body: %s", body)
}
expectedAuth := "PVEAPIToken=user@pam!token=secret-value"
if gotAuth != expectedAuth {
t.Errorf("expected auth header %q, got %q", expectedAuth, gotAuth)
}
}
func TestClientGetRequestPath(t *testing.T) {
var gotPath string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer server.Close()
client := NewClient([]string{server.URL}, "tok", false, 5)
client.httpClient = server.Client()
_, err := client.Get("/nodes/pve1/status")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "/api2/json/nodes/pve1/status"
if gotPath != expected {
t.Errorf("expected path %q, got %q", expected, gotPath)
}
}
func TestClientFailover(t *testing.T) {
callCount := 0
// First server returns 500
server1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("error"))
}))
defer server1.Close()
// Second server succeeds
server2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":"ok"}`))
}))
defer server2.Close()
// Both test servers use different TLS certs; we need a client that trusts both.
// Since httptest servers each have their own CA, we create an insecure client.
client := NewClient([]string{server1.URL, server2.URL}, "tok", true, 5)
body, err := client.Get("/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"data":"ok"}` {
t.Errorf("unexpected body: %s", body)
}
if callCount != 2 {
t.Errorf("expected 2 calls (one failed, one succeeded), got %d", callCount)
}
}
func TestClientAllHostsFail(t *testing.T) {
server1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server1.Close()
server2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer server2.Close()
client := NewClient([]string{server1.URL, server2.URL}, "tok", true, 5)
_, err := client.Get("/test")
if err == nil {
t.Fatal("expected error when all hosts fail")
}
if !strings.Contains(err.Error(), "all PVE hosts failed") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestClientMaxConcurrent(t *testing.T) {
client := NewClient([]string{"https://localhost"}, "tok", false, 10)
if client.MaxConcurrent() != 10 {
t.Errorf("expected MaxConcurrent=10, got %d", client.MaxConcurrent())
}
}