Handle VM guest agent errors without marking nodes unhealthy (related to #736)
This commit is contained in:
parent
37d0b7927c
commit
4c1da82e28
2 changed files with 83 additions and 28 deletions
|
|
@ -44,6 +44,33 @@ var transientRateLimitStatusCodes = map[int]struct{}{
|
||||||
504: {},
|
504: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isVMSpecificError reports whether an error string is scoped to a single VM/guest agent
|
||||||
|
// and should not be treated as a node connectivity failure.
|
||||||
|
func isVMSpecificError(errStr string) bool {
|
||||||
|
if errStr == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(errStr)
|
||||||
|
|
||||||
|
if strings.Contains(lower, "no qemu guest agent") ||
|
||||||
|
strings.Contains(lower, "qemu guest agent is not running") ||
|
||||||
|
strings.Contains(lower, "guest agent") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// QMP guest agent operations can time out or fail per-VM (e.g. guest-get-fsinfo).
|
||||||
|
// These aren't node connectivity issues and should not mark endpoints unhealthy.
|
||||||
|
if strings.Contains(lower, "qmp command") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(lower, "guest-get-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NewClusterClient creates a new cluster-aware client
|
// NewClusterClient creates a new cluster-aware client
|
||||||
func NewClusterClient(name string, config ClientConfig, endpoints []string) *ClusterClient {
|
func NewClusterClient(name string, config ClientConfig, endpoints []string) *ClusterClient {
|
||||||
cc := &ClusterClient{
|
cc := &ClusterClient{
|
||||||
|
|
@ -120,17 +147,9 @@ func (cc *ClusterClient) initialHealthCheck() {
|
||||||
cc.mu.Lock()
|
cc.mu.Lock()
|
||||||
|
|
||||||
// Check if error is VM-specific (shouldn't affect health)
|
// Check if error is VM-specific (shouldn't affect health)
|
||||||
isVMSpecificError := false
|
vmSpecificErr := err != nil && isVMSpecificError(err.Error())
|
||||||
if err != nil {
|
|
||||||
errStr := err.Error()
|
|
||||||
if strings.Contains(errStr, "No QEMU guest agent") ||
|
|
||||||
strings.Contains(errStr, "QEMU guest agent is not running") ||
|
|
||||||
strings.Contains(errStr, "guest agent") {
|
|
||||||
isVMSpecificError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil || isVMSpecificError {
|
if err == nil || vmSpecificErr {
|
||||||
// Node is healthy - create a proper client with full timeout for actual use
|
// Node is healthy - create a proper client with full timeout for actual use
|
||||||
fullCfg := cc.config
|
fullCfg := cc.config
|
||||||
fullCfg.Host = ep
|
fullCfg.Host = ep
|
||||||
|
|
@ -149,7 +168,7 @@ func (cc *ClusterClient) initialHealthCheck() {
|
||||||
delete(cc.lastError, ep)
|
delete(cc.lastError, ep)
|
||||||
cc.lastHealthCheck[ep] = time.Now()
|
cc.lastHealthCheck[ep] = time.Now()
|
||||||
cc.clients[ep] = fullClient // Store the full client, not test client
|
cc.clients[ep] = fullClient // Store the full client, not test client
|
||||||
if isVMSpecificError {
|
if vmSpecificErr {
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Str("cluster", cc.name).
|
Str("cluster", cc.name).
|
||||||
Str("endpoint", ep).
|
Str("endpoint", ep).
|
||||||
|
|
@ -456,17 +475,9 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
// Check if error is VM-specific (shouldn't prevent recovery)
|
// Check if error is VM-specific (shouldn't prevent recovery)
|
||||||
isVMSpecificError := false
|
vmSpecificErr := err != nil && isVMSpecificError(err.Error())
|
||||||
if err != nil {
|
|
||||||
errStr := err.Error()
|
|
||||||
if strings.Contains(errStr, "No QEMU guest agent") ||
|
|
||||||
strings.Contains(errStr, "QEMU guest agent is not running") ||
|
|
||||||
strings.Contains(errStr, "guest agent") {
|
|
||||||
isVMSpecificError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil || isVMSpecificError {
|
if err == nil || vmSpecificErr {
|
||||||
recoveredEndpoints <- ep
|
recoveredEndpoints <- ep
|
||||||
|
|
||||||
// Store the client with original timeout
|
// Store the client with original timeout
|
||||||
|
|
@ -480,7 +491,7 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
|
||||||
cc.clients[ep] = fullClient
|
cc.clients[ep] = fullClient
|
||||||
cc.mu.Unlock()
|
cc.mu.Unlock()
|
||||||
|
|
||||||
if isVMSpecificError {
|
if vmSpecificErr {
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("cluster", cc.name).
|
Str("cluster", cc.name).
|
||||||
Str("endpoint", ep).
|
Str("endpoint", ep).
|
||||||
|
|
@ -578,17 +589,14 @@ func (cc *ClusterClient) executeWithFailover(ctx context.Context, fn func(*Clien
|
||||||
// Error 403 for storage operations means permission issue, not node health issue
|
// Error 403 for storage operations means permission issue, not node health issue
|
||||||
// Error 500 with "No QEMU guest agent configured" means VM-specific issue, not node failure
|
// Error 500 with "No QEMU guest agent configured" means VM-specific issue, not node failure
|
||||||
// Error 500 with "QEMU guest agent is not running" means VM-specific issue, not node failure
|
// Error 500 with "QEMU guest agent is not running" means VM-specific issue, not node failure
|
||||||
// Error 500 with any "guest agent" message means VM-specific issue, not node failure
|
// Error 500 with any guest agent/QMP message means VM-specific issue, not node failure
|
||||||
// Error 400 with "ds" parameter error means Proxmox 9.x doesn't support RRD data source filtering
|
// Error 400 with "ds" parameter error means Proxmox 9.x doesn't support RRD data source filtering
|
||||||
// JSON unmarshal errors are data format issues, not connectivity problems
|
// JSON unmarshal errors are data format issues, not connectivity problems
|
||||||
// Context deadline/timeout errors on storage endpoints mean storage issues, not node unreachability
|
// Context deadline/timeout errors on storage endpoints mean storage issues, not node unreachability
|
||||||
if strings.Contains(errStr, "595") ||
|
if isVMSpecificError(errStr) ||
|
||||||
|
strings.Contains(errStr, "595") ||
|
||||||
(strings.Contains(errStr, "500") && strings.Contains(errStr, "hostname lookup")) ||
|
(strings.Contains(errStr, "500") && strings.Contains(errStr, "hostname lookup")) ||
|
||||||
(strings.Contains(errStr, "500") && strings.Contains(errStr, "Name or service not known")) ||
|
(strings.Contains(errStr, "500") && strings.Contains(errStr, "Name or service not known")) ||
|
||||||
(strings.Contains(errStr, "500") && strings.Contains(errStr, "No QEMU guest agent configured")) ||
|
|
||||||
(strings.Contains(errStr, "500") && strings.Contains(errStr, "QEMU guest agent is not running")) ||
|
|
||||||
(strings.Contains(errStr, "500") && strings.Contains(errStr, "guest agent")) ||
|
|
||||||
strings.Contains(errStr, "guest agent") ||
|
|
||||||
(strings.Contains(errStr, "403") && (strings.Contains(errStr, "storage") || strings.Contains(errStr, "datastore"))) ||
|
(strings.Contains(errStr, "403") && (strings.Contains(errStr, "storage") || strings.Contains(errStr, "datastore"))) ||
|
||||||
strings.Contains(errStr, "permission denied") ||
|
strings.Contains(errStr, "permission denied") ||
|
||||||
(strings.Contains(errStr, "400") && strings.Contains(errStr, "\"ds\"") && strings.Contains(errStr, "property is not defined in schema")) ||
|
(strings.Contains(errStr, "400") && strings.Contains(errStr, "\"ds\"") && strings.Contains(errStr, "property is not defined in schema")) ||
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,50 @@ func TestClusterClientHandlesRateLimitWithoutMarkingUnhealthy(t *testing.T) {
|
||||||
t.Fatalf("expected at least 2 requests to backend, got %d", requestCount)
|
t.Fatalf("expected at least 2 requests to backend, got %d", requestCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClusterClientIgnoresGuestAgentTimeoutForHealth(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api2/json/nodes":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"data":[{"node":"test","status":"online","cpu":0,"maxcpu":1,"mem":0,"maxmem":1,"disk":0,"maxdisk":1,"uptime":1,"level":"normal"}]}`)
|
||||||
|
case "/api2/json/nodes/test/qemu/100/agent/get-fsinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"data":null,"message":"VM 100 qmp command 'guest-get-fsinfo' failed - got timeout\n"}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := ClientConfig{
|
||||||
|
Host: server.URL,
|
||||||
|
TokenName: "pulse@pve!token",
|
||||||
|
TokenValue: "sometokenvalue",
|
||||||
|
VerifySSL: false,
|
||||||
|
Timeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := NewClusterClient("test-cluster", cfg, []string{server.URL})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := cc.GetVMFSInfo(ctx, "test", 100)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected VM guest agent timeout error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
health := cc.GetHealthStatusWithErrors()
|
||||||
|
endpointHealth, ok := health[server.URL]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected health entry for endpoint %s", server.URL)
|
||||||
|
}
|
||||||
|
if !endpointHealth.Healthy {
|
||||||
|
t.Fatalf("expected endpoint to remain healthy after VM-specific guest agent error, got %+v", endpointHealth)
|
||||||
|
}
|
||||||
|
if endpointHealth.LastError != "" {
|
||||||
|
t.Fatalf("expected last error to remain empty for VM-specific failures, got %q", endpointHealth.LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue