mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Merge commit 'e106c04d2ed08d0846b3a5e5f91c669971583c33' into release/v6.4
Change-source: pulse-maintainer
This commit is contained in:
@@ -17,6 +17,22 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
|
Direct PBS backup polling classifies typed API and authentication failures by
|
||||||
|
the response status exposed by `pbs.HTTPStatus`, including wrapped errors.
|
||||||
|
A 5xx gateway or server response remains transient even when its body quotes
|
||||||
|
an upstream “API error 403” or “API error 404”; that text must not erase the
|
||||||
|
last known backup inventory. Genuine 4xx responses remain terminal under the
|
||||||
|
existing cache policy. The legacy untyped-error fallback is unchanged.
|
||||||
|
Retaining cached inventory does not establish a successful poll or fresh
|
||||||
|
backup evidence.
|
||||||
|
|
||||||
|
Verification: `TestPollPBSBackups_PreservesCacheOnTransientDatastoreError` and
|
||||||
|
`TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError` in
|
||||||
|
`internal/monitoring/monitor_backups_readstate_test.go` exercise actual HTTP
|
||||||
|
fixtures for 500, 502 quoting 403, 503 quoting 404, and genuine 401/403/404.
|
||||||
|
These tests prove cache retention/removal, not installed PBS wake, service
|
||||||
|
restart, or notification receipt.
|
||||||
|
|
||||||
TrueNAS CRITICAL, ALERT and EMERGENCY native levels project as canonical critical incidents. EMERGENCY must not be discarded as an unknown level: repeated observations retain the active incident rather than supplying false recovery evidence. Provider projection tests cover every documented native level and normalized input.
|
TrueNAS CRITICAL, ALERT and EMERGENCY native levels project as canonical critical incidents. EMERGENCY must not be discarded as an unknown level: repeated observations retain the active incident rather than supplying false recovery evidence. Provider projection tests cover every documented native level and normalized input.
|
||||||
|
|
||||||
TrueNAS native alert projection preserves the trimmed, uppercase provider level in ResourceIncident.NativeSeverity. INFO and NOTICE retain the same canonical monitor risk; consumers must not lose their distinct actionability when projecting provider evidence.
|
TrueNAS native alert projection preserves the trimmed, uppercase provider level in ResourceIncident.NativeSeverity. INFO and NOTICE retain the same canonical monitor risk; consumers must not lose their distinct actionability when projecting provider evidence.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package monitoring
|
package monitoring
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
||||||
|
)
|
||||||
|
|
||||||
// shouldPreservePBSBackupsWithTerminal preserves stale PBS backups only when all
|
// shouldPreservePBSBackupsWithTerminal preserves stale PBS backups only when all
|
||||||
// datastore fetches failed and at least one failure was non-terminal.
|
// datastore fetches failed and at least one failure was non-terminal.
|
||||||
@@ -17,6 +21,10 @@ func shouldReuseCachedPBSBackups(err error) bool {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if status, ok := pbs.HTTPStatus(err); ok {
|
||||||
|
return status < 400 || status >= 500
|
||||||
|
}
|
||||||
|
// Retain compatibility with untyped errors from older callers.
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "api error 4") {
|
if strings.Contains(strings.ToLower(err.Error()), "api error 4") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -995,3 +995,117 @@ func TestRetirePVEInstanceRuntimeClearsPBSGuestConfirmations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/admin/datastore/archive/groups") {
|
||||||
|
http.Error(w, `{"errors":"datastore does not exist"}`, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := pbs.NewClient(pbs.ClientConfig{
|
||||||
|
Host: server.URL,
|
||||||
|
TokenName: "root@pam!token",
|
||||||
|
TokenValue: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create PBS client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Monitor{state: models.NewState()}
|
||||||
|
m.state.UpdatePBSBackups("pbs1", []models.PBSBackup{
|
||||||
|
{
|
||||||
|
ID: "pbs-pbs1-archive--vm-100-1700000000",
|
||||||
|
Instance: "pbs1",
|
||||||
|
Datastore: "archive",
|
||||||
|
Namespace: "",
|
||||||
|
BackupType: "vm",
|
||||||
|
VMID: "100",
|
||||||
|
BackupTime: time.Unix(1700000000, 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
m.pollPBSBackups(context.Background(), "pbs1", client, []models.PBSDatastore{
|
||||||
|
{Name: "archive"},
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot := m.state.GetSnapshot()
|
||||||
|
for _, backup := range snapshot.PBSBackups {
|
||||||
|
if backup.Instance == "pbs1" {
|
||||||
|
t.Fatalf("expected stale backups to be removed after terminal error, found: %+v", backup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollPBSBackups_PreservesCacheOnTransientDatastoreError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"server error", http.StatusInternalServerError, "temporary server issue"},
|
||||||
|
{"gateway quoting forbidden", http.StatusBadGateway, "upstream API error 403: permission denied"},
|
||||||
|
{"unavailable quoting missing datastore", http.StatusServiceUnavailable, "upstream API error 404: datastore does not exist"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/admin/datastore/archive/groups") {
|
||||||
|
http.Error(w, tc.body, tc.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := pbs.NewClient(pbs.ClientConfig{
|
||||||
|
Host: server.URL,
|
||||||
|
TokenName: "root@pam!token",
|
||||||
|
TokenValue: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create PBS client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Monitor{state: models.NewState()}
|
||||||
|
original := models.PBSBackup{
|
||||||
|
ID: "pbs-pbs1-archive--vm-100-1700000000",
|
||||||
|
Instance: "pbs1",
|
||||||
|
Datastore: "archive",
|
||||||
|
Namespace: "",
|
||||||
|
BackupType: "vm",
|
||||||
|
VMID: "100",
|
||||||
|
BackupTime: time.Unix(1700000000, 0),
|
||||||
|
}
|
||||||
|
m.state.UpdatePBSBackups("pbs1", []models.PBSBackup{original})
|
||||||
|
|
||||||
|
m.pollPBSBackups(context.Background(), "pbs1", client, []models.PBSDatastore{
|
||||||
|
{Name: "archive"},
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot := m.state.GetSnapshot()
|
||||||
|
var found bool
|
||||||
|
for _, backup := range snapshot.PBSBackups {
|
||||||
|
if backup.Instance == "pbs1" && backup.ID == original.ID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected cached backup to be preserved on transient error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package monitoring
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.Contains(r.URL.Path, "/admin/datastore/archive/groups") {
|
|
||||||
http.Error(w, `{"errors":"datastore does not exist"}`, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client, err := pbs.NewClient(pbs.ClientConfig{
|
|
||||||
Host: server.URL,
|
|
||||||
TokenName: "root@pam!token",
|
|
||||||
TokenValue: "secret",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create PBS client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m := &Monitor{state: models.NewState()}
|
|
||||||
m.state.UpdatePBSBackups("pbs1", []models.PBSBackup{
|
|
||||||
{
|
|
||||||
ID: "pbs-pbs1-archive--vm-100-1700000000",
|
|
||||||
Instance: "pbs1",
|
|
||||||
Datastore: "archive",
|
|
||||||
Namespace: "",
|
|
||||||
BackupType: "vm",
|
|
||||||
VMID: "100",
|
|
||||||
BackupTime: time.Unix(1700000000, 0),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
m.pollPBSBackups(context.Background(), "pbs1", client, []models.PBSDatastore{
|
|
||||||
{Name: "archive"},
|
|
||||||
})
|
|
||||||
|
|
||||||
snapshot := m.state.GetSnapshot()
|
|
||||||
for _, backup := range snapshot.PBSBackups {
|
|
||||||
if backup.Instance == "pbs1" {
|
|
||||||
t.Fatalf("expected stale backups to be removed after terminal error, found: %+v", backup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPollPBSBackups_PreservesCacheOnTransientDatastoreError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.Contains(r.URL.Path, "/admin/datastore/archive/groups") {
|
|
||||||
http.Error(w, `{"errors":"temporary server issue"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client, err := pbs.NewClient(pbs.ClientConfig{
|
|
||||||
Host: server.URL,
|
|
||||||
TokenName: "root@pam!token",
|
|
||||||
TokenValue: "secret",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create PBS client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m := &Monitor{state: models.NewState()}
|
|
||||||
original := models.PBSBackup{
|
|
||||||
ID: "pbs-pbs1-archive--vm-100-1700000000",
|
|
||||||
Instance: "pbs1",
|
|
||||||
Datastore: "archive",
|
|
||||||
Namespace: "",
|
|
||||||
BackupType: "vm",
|
|
||||||
VMID: "100",
|
|
||||||
BackupTime: time.Unix(1700000000, 0),
|
|
||||||
}
|
|
||||||
m.state.UpdatePBSBackups("pbs1", []models.PBSBackup{original})
|
|
||||||
|
|
||||||
m.pollPBSBackups(context.Background(), "pbs1", client, []models.PBSDatastore{
|
|
||||||
{Name: "archive"},
|
|
||||||
})
|
|
||||||
|
|
||||||
snapshot := m.state.GetSnapshot()
|
|
||||||
var found bool
|
|
||||||
for _, backup := range snapshot.PBSBackups {
|
|
||||||
if backup.Instance == "pbs1" && backup.ID == original.ID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("expected cached backup to be preserved on transient error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-3
@@ -331,7 +331,9 @@ func (e *apiHTTPError) Error() string {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
func pbsHTTPStatus(err error) (int, bool) {
|
// HTTPStatus returns the response status from a PBS API or authentication error,
|
||||||
|
// including wrapped errors. The response body is never used for classification.
|
||||||
|
func HTTPStatus(err error) (int, bool) {
|
||||||
var apiErr *apiHTTPError
|
var apiErr *apiHTTPError
|
||||||
if errors.As(err, &apiErr) {
|
if errors.As(err, &apiErr) {
|
||||||
return apiErr.status, true
|
return apiErr.status, true
|
||||||
@@ -346,12 +348,12 @@ func pbsHTTPStatus(err error) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPBSPermissionError(err error) bool {
|
func isPBSPermissionError(err error) bool {
|
||||||
status, ok := pbsHTTPStatus(err)
|
status, ok := HTTPStatus(err)
|
||||||
return ok && (status == http.StatusUnauthorized || status == http.StatusForbidden)
|
return ok && (status == http.StatusUnauthorized || status == http.StatusForbidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPBSNotFoundError(err error) bool {
|
func isPBSNotFoundError(err error) bool {
|
||||||
status, ok := pbsHTTPStatus(err)
|
status, ok := HTTPStatus(err)
|
||||||
return ok && status == http.StatusNotFound
|
return ok && status == http.StatusNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ func TestClient_GetNodeName_SuperuserPermissionFailureRetries(t *testing.T) {
|
|||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
t.Fatal("first GetNodeName: expected error")
|
t.Fatal("first GetNodeName: expected error")
|
||||||
}
|
}
|
||||||
if got, ok := pbsHTTPStatus(firstErr); !ok || got != status {
|
if got, ok := HTTPStatus(firstErr); !ok || got != status {
|
||||||
t.Fatalf("first GetNodeName status = (%d, %v), want (%d, true): %v", got, ok, status, firstErr)
|
t.Fatalf("first GetNodeName status = (%d, %v), want (%d, true): %v", got, ok, status, firstErr)
|
||||||
}
|
}
|
||||||
name, err := client.GetNodeName(context.Background())
|
name, err := client.GetNodeName(context.Background())
|
||||||
@@ -632,7 +632,7 @@ func TestClient_GetNodeName_TransientHTTPFailuresRetryAndRecover(t *testing.T) {
|
|||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
t.Fatal("first GetNodeName: expected error")
|
t.Fatal("first GetNodeName: expected error")
|
||||||
}
|
}
|
||||||
if got, ok := pbsHTTPStatus(firstErr); !ok || got != tc.status {
|
if got, ok := HTTPStatus(firstErr); !ok || got != tc.status {
|
||||||
t.Fatalf("first GetNodeName status = (%d, %v), want (%d, true): %v", got, ok, tc.status, firstErr)
|
t.Fatalf("first GetNodeName status = (%d, %v), want (%d, true): %v", got, ok, tc.status, firstErr)
|
||||||
}
|
}
|
||||||
name, err := client.GetNodeName(context.Background())
|
name, err := client.GetNodeName(context.Background())
|
||||||
@@ -795,7 +795,7 @@ func TestClient_GetNodeName_ConcurrentTransientFailureIsSingleFlight(t *testing.
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("concurrent GetNodeName: expected transient error")
|
t.Fatal("concurrent GetNodeName: expected transient error")
|
||||||
}
|
}
|
||||||
if got, ok := pbsHTTPStatus(err); !ok || got != http.StatusServiceUnavailable {
|
if got, ok := HTTPStatus(err); !ok || got != http.StatusServiceUnavailable {
|
||||||
t.Fatalf("concurrent GetNodeName status = (%d, %v), want (503, true): %v", got, ok, err)
|
t.Fatalf("concurrent GetNodeName status = (%d, %v), want (503, true): %v", got, ok, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user