mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 16:20:03 +00:00
192aee6acc
A node joining a PVE cluster after setup could never be trusted when the primary was fingerprint-pinned (#1664). Two independent gaps: cluster discovery validation only relaxed TLS when client construction failed, but a pinned-fingerprint mismatch surfaces from the first API call, so the member was judged not-a-Proxmox-node and its captured fingerprint discarded; and the cluster client's TOFU refresh only ran for endpoints that already had a per-endpoint fingerprint, handling rotation but never first trust. Validation now retries with the member's own captured fingerprint before rejecting it, TOFU capture runs on first use, and discovery failures now distinguish DNS, refused, timeout, and TLS causes in the endpoint error instead of one generic message. Contract-Neutral: cluster discovery TLS validation bug fix; no wire contract or payload change
616 lines
20 KiB
Go
616 lines
20 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestClusterClient_GetCephStatus(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":{"health":{"status":"HEALTH_OK"}}}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
ctx := context.Background()
|
|
status, err := cc.GetCephStatus(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetCephStatus failed: %v", err)
|
|
}
|
|
if status.Health.Status != "HEALTH_OK" {
|
|
t.Errorf("expected HEALTH_OK, got %s", status.Health.Status)
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetVMMemoryAvailabilityFromAgent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api2/json/nodes":
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
case "/api2/json/nodes/node1/qemu/1501/agent/file-read":
|
|
fmt.Fprint(w, `{"data":{"content":"MemTotal: 7796964 kB\nMemFree: 444824 kB\nMemAvailable: 1872820 kB\nCached: 1580464 kB\n","truncated":false}}`)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cc := NewClusterClient(
|
|
"test",
|
|
ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"},
|
|
[]string{server.URL},
|
|
nil,
|
|
)
|
|
availability, err := cc.GetVMMemoryAvailabilityFromAgent(context.Background(), "node1", 1501)
|
|
if err != nil {
|
|
t.Fatalf("GetVMMemoryAvailabilityFromAgent() error = %v", err)
|
|
}
|
|
if availability.Source != "meminfo-available" {
|
|
t.Fatalf("source = %q, want meminfo-available", availability.Source)
|
|
}
|
|
if availability.EffectiveAvailable != 1872820*1024 {
|
|
t.Fatalf("available = %d, want %d", availability.EffectiveAvailable, 1872820*1024)
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetVMSnapshots(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"name":"snap1","description":"first"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
ctx := context.Background()
|
|
snaps, err := cc.GetVMSnapshots(ctx, "node1", 100)
|
|
if err != nil {
|
|
t.Fatalf("GetVMSnapshots failed: %v", err)
|
|
}
|
|
if len(snaps) != 1 {
|
|
t.Errorf("expected 1 snapshot, got %d", len(snaps))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetStorageContent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"volid":"local:iso/ubuntu.iso","format":"iso","size":1000,"content":"backup"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
ctx := context.Background()
|
|
content, err := cc.GetStorageContent(ctx, "node1", "local")
|
|
if err != nil {
|
|
t.Fatalf("GetStorageContent failed: %v", err)
|
|
}
|
|
if len(content) != 1 {
|
|
t.Errorf("expected 1 item, got %d", len(content))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_ConfiguredAuthorityDoesNotWaitForMemberRecovery(t *testing.T) {
|
|
var authorityDataCalls atomic.Int32
|
|
authority := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api2/json/nodes/node1/qemu/100/snapshot":
|
|
authorityDataCalls.Add(1)
|
|
fmt.Fprint(w, `{"data":[{"name":"snap-authority","description":"cluster snapshot"}]}`)
|
|
case "/api2/json/nodes/node1/storage/local/content":
|
|
authorityDataCalls.Add(1)
|
|
fmt.Fprint(w, `{"data":[{"volid":"local:backup/vzdump-qemu-100.vma.zst","format":"vma.zst","size":1000,"content":"backup"}]}`)
|
|
default:
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
}
|
|
}))
|
|
defer authority.Close()
|
|
|
|
recoveryStarted := make(chan struct{}, 2)
|
|
releaseRecovery := make(chan struct{})
|
|
recoveryReleased := false
|
|
|
|
var memberDataCalls atomic.Int32
|
|
newMember := func() *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
recoveryStarted <- struct{}{}
|
|
<-releaseRecovery
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
memberDataCalls.Add(1)
|
|
fmt.Fprint(w, `{"data":[]}`)
|
|
}))
|
|
}
|
|
memberA := newMember()
|
|
defer memberA.Close()
|
|
memberB := newMember()
|
|
defer memberB.Close()
|
|
defer func() {
|
|
if !recoveryReleased {
|
|
close(releaseRecovery)
|
|
}
|
|
}()
|
|
|
|
cfg := ClientConfig{
|
|
Host: authority.URL,
|
|
TokenName: "u@p!t",
|
|
TokenValue: "v",
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
authorityClient, err := NewClient(cfg)
|
|
if err != nil {
|
|
t.Fatalf("create configured authority client: %v", err)
|
|
}
|
|
|
|
endpoints := []string{authority.URL, memberA.URL, memberB.URL}
|
|
cc := &ClusterClient{
|
|
name: "remote-cluster",
|
|
clients: map[string]*Client{authority.URL: authorityClient},
|
|
endpoints: endpoints,
|
|
endpointFingerprints: make(map[string]string),
|
|
nodeHealth: map[string]bool{
|
|
authority.URL: true,
|
|
memberA.URL: false,
|
|
memberB.URL: false,
|
|
},
|
|
lastHealthCheck: make(map[string]time.Time),
|
|
lastError: map[string]string{
|
|
memberA.URL: "Network unreachable - check network connectivity to Proxmox host",
|
|
memberB.URL: "Network unreachable - check network connectivity to Proxmox host",
|
|
},
|
|
config: cfg,
|
|
rateLimitUntil: make(map[string]time.Time),
|
|
}
|
|
|
|
snapshotResult := make(chan error, 1)
|
|
go func() {
|
|
snapshots, snapshotErr := cc.GetVMSnapshots(context.Background(), "node1", 100)
|
|
if snapshotErr == nil && (len(snapshots) != 1 || snapshots[0].Name != "snap-authority") {
|
|
snapshotErr = fmt.Errorf("unexpected authority snapshots: %+v", snapshots)
|
|
}
|
|
snapshotResult <- snapshotErr
|
|
}()
|
|
|
|
select {
|
|
case <-recoveryStarted:
|
|
// Recovery is actively blocked on a member-only address.
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("member recovery did not start")
|
|
}
|
|
|
|
select {
|
|
case err := <-snapshotResult:
|
|
if err != nil {
|
|
t.Fatalf("snapshot polling through configured authority failed: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("snapshot polling waited for unreachable member recovery")
|
|
}
|
|
|
|
health := cc.GetHealthStatus()
|
|
if !health[authority.URL] || health[memberA.URL] || health[memberB.URL] {
|
|
t.Fatalf("expected healthy authority with degraded member evidence, got %+v", health)
|
|
}
|
|
|
|
close(releaseRecovery)
|
|
recoveryReleased = true
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
health = cc.GetHealthStatus()
|
|
cc.mu.RLock()
|
|
recoveryDone := !cc.recoveryInProgress
|
|
cc.mu.RUnlock()
|
|
if health[memberA.URL] && health[memberB.URL] && recoveryDone {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("expected member refresh to recover both failovers, got %+v", health)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
for i := 0; i < 20; i++ {
|
|
content, err := cc.GetStorageContent(context.Background(), "node1", "local")
|
|
if err != nil {
|
|
t.Fatalf("storage content poll %d failed: %v", i+1, err)
|
|
}
|
|
if len(content) != 1 {
|
|
t.Fatalf("storage content poll %d returned %+v", i+1, content)
|
|
}
|
|
}
|
|
if got := memberDataCalls.Load(); got != 0 {
|
|
t.Fatalf("expected recovered members to remain failovers, got %d member data calls", got)
|
|
}
|
|
if got := authorityDataCalls.Load(); got != 21 {
|
|
t.Fatalf("expected all API-only data from configured authority, got %d calls", got)
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_RecoverUnhealthyNodes(t *testing.T) {
|
|
var callCount int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&callCount, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
// Manually mark as unhealthy
|
|
cc.mu.Lock()
|
|
cc.nodeHealth[server.URL] = false
|
|
delete(cc.lastHealthCheck, server.URL) // Ensure no throttle
|
|
cc.mu.Unlock()
|
|
|
|
cc.recoverUnhealthyNodes(context.Background())
|
|
|
|
// Wait for async recovery
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
cc.mu.RLock()
|
|
healthy := cc.nodeHealth[server.URL]
|
|
cc.mu.RUnlock()
|
|
|
|
if !healthy {
|
|
t.Error("expected node to be recovered and marked healthy")
|
|
}
|
|
if atomic.LoadInt32(&callCount) == 0 {
|
|
t.Error("expected recovery check to be performed")
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_InitialHealthCheck_Failures(t *testing.T) {
|
|
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server1.Close()
|
|
|
|
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server2.Close()
|
|
|
|
cfg := ClientConfig{Host: server1.URL, TokenName: "u@p!t", TokenValue: "v", Timeout: 100 * time.Millisecond}
|
|
// initialHealthCheck happens in NewClusterClient
|
|
cc := NewClusterClient("test", cfg, []string{server1.URL, server2.URL}, nil)
|
|
|
|
health := cc.GetHealthStatus()
|
|
// NewClusterClient already calls initialHealthCheck which should mark them unhealthy
|
|
if healthy, ok := health[server1.URL]; !ok || healthy {
|
|
t.Errorf("expected node 1 to be marked unhealthy initially, got healthy=%v", healthy)
|
|
}
|
|
if healthy, ok := health[server2.URL]; !ok || healthy {
|
|
t.Errorf("expected node 2 to be marked unhealthy initially, got healthy=%v", healthy)
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetNodeRRDData(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"time":12345678,"cpu":0.5}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
data, err := cc.GetNodeRRDData(context.Background(), "node1", "hour", "AVERAGE", []string{"cpu"})
|
|
if err != nil {
|
|
t.Fatalf("GetNodeRRDData failed: %v", err)
|
|
}
|
|
if len(data) != 1 {
|
|
t.Errorf("expected 1 data point, got %d", len(data))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetLXCRRDData(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"time":12345678,"cpu":0.5}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
data, err := cc.GetLXCRRDData(context.Background(), "node1", 100, "hour", "AVERAGE", []string{"cpu"})
|
|
if err != nil {
|
|
t.Fatalf("GetLXCRRDData failed: %v", err)
|
|
}
|
|
if len(data) != 1 {
|
|
t.Errorf("expected 1 data point, got %d", len(data))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetVMRRDData(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"time":12345678,"cpu":0.5}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
data, err := cc.GetVMRRDData(context.Background(), "node1", 100, "hour", "AVERAGE", []string{"cpu"})
|
|
if err != nil {
|
|
t.Fatalf("GetVMRRDData failed: %v", err)
|
|
}
|
|
if len(data) != 1 {
|
|
t.Errorf("expected 1 data point, got %d", len(data))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetVMs(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"vmid":100,"name":"vm1","status":"running"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
vms, err := cc.GetVMs(context.Background(), "node1")
|
|
if err != nil {
|
|
t.Fatalf("GetVMs failed: %v", err)
|
|
}
|
|
if len(vms) != 1 {
|
|
t.Errorf("expected 1 VM, got %d", len(vms))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetContainers(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"vmid":200,"name":"ct1","status":"running"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
cts, err := cc.GetContainers(context.Background(), "node1")
|
|
if err != nil {
|
|
t.Fatalf("GetContainers failed: %v", err)
|
|
}
|
|
if len(cts) != 1 {
|
|
t.Errorf("expected 1 container, got %d", len(cts))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetStorage(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"storage":"local","type":"dir"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
storage, err := cc.GetStorage(context.Background(), "node1")
|
|
if err != nil {
|
|
t.Fatalf("GetStorage failed: %v", err)
|
|
}
|
|
if len(storage) != 1 {
|
|
t.Errorf("expected 1 storage, got %d", len(storage))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetBackupTasks(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"upid":"upid:1","type":"vzdump","status":"ok"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
tasks, err := cc.GetBackupTasks(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetBackupTasks failed: %v", err)
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Errorf("expected 1 task, got %d", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetContainerSnapshots(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"name":"snap1","description":"desc"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
snaps, err := cc.GetContainerSnapshots(context.Background(), "node1", 200)
|
|
if err != nil {
|
|
t.Fatalf("GetContainerSnapshots failed: %v", err)
|
|
}
|
|
if len(snaps) != 1 {
|
|
t.Errorf("expected 1 snapshot, got %d", len(snaps))
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetContainerConfig(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"data":{"hostname":"ct1"}}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
config, err := cc.GetContainerConfig(context.Background(), "node1", 200)
|
|
if err != nil {
|
|
t.Fatalf("GetContainerConfig failed: %v", err)
|
|
}
|
|
if config["hostname"] != "ct1" {
|
|
t.Errorf("expected hostname ct1, got %v", config["hostname"])
|
|
}
|
|
}
|
|
|
|
func TestClusterClient_GetTaskLog(t *testing.T) {
|
|
const upid = "UPID:node1:000E9F2C:0AC734B2:68A1B2C3:vzdump::root@pam:"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
|
|
return
|
|
}
|
|
if r.URL.Path != "/api2/json/nodes/node1/tasks/"+upid+"/log" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
fmt.Fprint(w, `{"data":[{"n":1,"t":"INFO: Starting Backup of VM 101 (qemu)"},{"n":2,"t":"INFO: Finished Backup of VM 101 (00:01:30)"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
|
|
|
|
lines, err := cc.GetTaskLog(context.Background(), "node1", upid)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskLog failed: %v", err)
|
|
}
|
|
if len(lines) != 2 {
|
|
t.Fatalf("expected 2 log lines, got %d", len(lines))
|
|
}
|
|
if lines[0].Text != "INFO: Starting Backup of VM 101 (qemu)" || lines[0].LineNumber != 1 {
|
|
t.Errorf("unexpected first line: %+v", lines[0])
|
|
}
|
|
}
|
|
|
|
// Issue #1664: refreshTOFUFingerprintAndRetry only refreshed endpoints that
|
|
// already had a per-endpoint fingerprint entry, so it recovered certificate
|
|
// rotation on known members but never first trust of a newly joined member.
|
|
// A new member has no entry and fell back to the primary's pinned
|
|
// fingerprint forever.
|
|
func TestClusterClient_TOFUFirstUse_TrustsNewMemberCert(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api2/json/nodes" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"data":[{"node":"member-b","status":"online"}]}`)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{
|
|
Host: server.URL,
|
|
TokenName: "root@pam!pulse",
|
|
TokenValue: "secret",
|
|
Fingerprint: "DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF:DE:AD:BE:EF",
|
|
}
|
|
// Single endpoint skips the initial health check; the member endpoint has
|
|
// no per-endpoint fingerprint entry, exactly like a freshly joined node.
|
|
cc := NewClusterClient("issue1664", cfg, []string{server.URL}, nil)
|
|
|
|
mismatch := errors.New("certificate fingerprint mismatch: expected DE:AD, got EF:B5")
|
|
client, err, refreshed := cc.refreshTOFUFingerprintAndRetry(context.Background(), server.URL, 2*time.Second, mismatch)
|
|
if !refreshed {
|
|
t.Fatal("expected TOFU refresh to run for an endpoint with no stored fingerprint")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("expected retry with freshly captured fingerprint to succeed, got %v", err)
|
|
}
|
|
if client == nil {
|
|
t.Fatal("expected a working client after first-use trust")
|
|
}
|
|
|
|
cc.mu.RLock()
|
|
stored := cc.endpointFingerprints[server.URL]
|
|
cc.mu.RUnlock()
|
|
if stored == "" {
|
|
t.Fatal("expected member fingerprint to be stored for future connections")
|
|
}
|
|
}
|
|
|
|
// Non-mismatch errors must not trigger TOFU capture.
|
|
func TestClusterClient_TOFURefresh_IgnoresNonTLSMismatch(t *testing.T) {
|
|
cfg := ClientConfig{Host: "https://127.0.0.1:1", TokenName: "u@p!t", TokenValue: "v"}
|
|
cc := NewClusterClient("issue1664b", cfg, []string{"https://127.0.0.1:1"}, nil)
|
|
|
|
plainErr := errors.New("connection refused")
|
|
_, err, refreshed := cc.refreshTOFUFingerprintAndRetry(context.Background(), "https://127.0.0.1:1", time.Second, plainErr)
|
|
if refreshed {
|
|
t.Fatal("expected no TOFU refresh for a non-mismatch error")
|
|
}
|
|
if !errors.Is(err, plainErr) {
|
|
t.Fatalf("expected original error back, got %v", err)
|
|
}
|
|
}
|