Add PDM HTTP alert source

This commit is contained in:
rcourtman
2026-05-13 11:30:31 +01:00
parent cfef1b67ae
commit 7827f2b077
4 changed files with 423 additions and 12 deletions
+9 -8
View File
@@ -6,10 +6,8 @@
// into a known-offline state. Resources that come back online emit a lazy
// resolve sentinel on the next observe trip -- there is no sweep goroutine.
//
// MVP: the real HTTP client is a follow-on; this lane ships only the
// interface, the diff machinery, and a nil-source guard at the call site.
// pdmAlertBridgeConfig is reserved for the future env-based credential
// surface; the MVP leaves it zero-valued.
// The real HTTP source is enabled only when the full PDM_API_URL,
// PDM_API_TOKEN, and PDM_API_TOKEN_SECRET environment contract is present.
package ai
import (
@@ -30,7 +28,7 @@ const (
// pdmAlertSource is the interface the bridge uses to fetch resource
// snapshots. Unit tests inject a deterministic fake; the real implementation
// (future) makes an authenticated HTTP call to /api2/json/resources.
// makes an authenticated HTTP call to /api2/extjs/resources/list.
type pdmAlertSource interface {
ResourceList(ctx context.Context) ([]pdmResource, error)
}
@@ -44,9 +42,12 @@ type pdmResource struct {
Status string // "online", "offline", "running", "stopped", "failed", "unknown"
}
// pdmAlertBridgeConfig is reserved for the forward-compatible credential
// surface (PDM_API_URL, PDM_API_TOKEN). MVP leaves it zero-valued.
type pdmAlertBridgeConfig struct{}
type pdmAlertBridgeConfig struct {
APIURL string
APIToken string
APITokenSecret string
InsecureSkipVerify bool
}
// pdmAlertBridge diffs PDM resource status across observe trips.
type pdmAlertBridge struct {
+226
View File
@@ -0,0 +1,226 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
)
const (
pdmAPIURLEnv = "PDM_API_URL"
pdmAPITokenEnv = "PDM_API_TOKEN"
pdmAPITokenSecretEnv = "PDM_API_TOKEN_SECRET"
pdmInsecureSkipVerifyEnv = "PDM_INSECURE_SKIP_VERIFY"
pdmResourcesListPath = "api2/extjs/resources/list"
pdmAPITokenAuthScheme = "PDMAPIToken"
pdmHTTPClientRequestTimeout = 10 * time.Second
)
type pdmHTTPClient struct {
baseURL *url.URL
httpClient *http.Client
tokenID string
tokenSecret string
}
func loadPDMAlertBridgeConfigFromEnv() pdmAlertBridgeConfig {
return pdmAlertBridgeConfig{
APIURL: strings.TrimSpace(os.Getenv(pdmAPIURLEnv)),
APIToken: strings.TrimSpace(os.Getenv(pdmAPITokenEnv)),
APITokenSecret: strings.TrimSpace(os.Getenv(pdmAPITokenSecretEnv)),
InsecureSkipVerify: parsePDMInsecureSkipVerify(os.Getenv(pdmInsecureSkipVerifyEnv)),
}
}
func parsePDMInsecureSkipVerify(raw string) bool {
enabled, err := strconv.ParseBool(strings.TrimSpace(raw))
return err == nil && enabled
}
func (cfg pdmAlertBridgeConfig) enabled() bool {
return cfg.APIURL != "" && cfg.APIToken != "" && cfg.APITokenSecret != ""
}
func newPDMAlertSourceFromEnv() pdmAlertSource {
client, err := newPDMHTTPClient(loadPDMAlertBridgeConfigFromEnv())
if err != nil {
return nil
}
if client == nil {
return nil
}
return client
}
func newPDMHTTPClient(cfg pdmAlertBridgeConfig) (*pdmHTTPClient, error) {
if !cfg.enabled() {
return nil, nil
}
baseURL, err := securityutil.NormalizeHTTPBaseURL(cfg.APIURL, "https")
if err != nil {
return nil, fmt.Errorf("invalid PDM API URL: %w", err)
}
httpClient := tlsutil.CreateHTTPClientWithTimeout(!cfg.InsecureSkipVerify, "", pdmHTTPClientRequestTimeout)
if httpClient == nil {
return nil, fmt.Errorf("create PDM HTTP client")
}
return &pdmHTTPClient{
baseURL: baseURL,
httpClient: httpClient,
tokenID: cfg.APIToken,
tokenSecret: cfg.APITokenSecret,
}, nil
}
func (c *pdmHTTPClient) ResourceList(ctx context.Context) ([]pdmResource, error) {
if c == nil {
return nil, fmt.Errorf("PDM HTTP client is nil")
}
endpoint := securityutil.AppendURLPath(c.baseURL, pdmResourcesListPath)
req, err := securityutil.NewValidatedRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create PDM resources request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", pdmAPITokenAuthScheme+" "+c.tokenID+":"+c.tokenSecret)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch PDM resources: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("fetch PDM resources: unexpected status %d", resp.StatusCode)
}
var envelope pdmResourcesResponse
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return nil, fmt.Errorf("decode PDM resources response: %w", err)
}
return envelope.resources(), nil
}
type pdmResourcesResponse struct {
Data []pdmRemoteResources `json:"data"`
}
type pdmRemoteResources struct {
Remote string `json:"remote"`
Error string `json:"error,omitempty"`
Resources []pdmRawResource `json:"resources"`
}
type pdmRawResource struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Node string `json:"node"`
Storage string `json:"storage"`
Status string `json:"status"`
Uptime uint64 `json:"uptime"`
Maintenance *string `json:"maintenance"`
}
func (r pdmResourcesResponse) resources() []pdmResource {
var out []pdmResource
for _, remote := range r.Data {
remoteID := strings.TrimSpace(remote.Remote)
if remoteID == "" {
continue
}
for _, raw := range remote.Resources {
resource, ok := raw.resource(remoteID)
if ok {
out = append(out, resource)
}
}
}
return out
}
func (r pdmRawResource) resource(remoteID string) (pdmResource, bool) {
resourceType, ok := pdmBridgeResourceType(r.Type)
if !ok {
return pdmResource{}, false
}
name := strings.TrimSpace(r.resourceName())
if name == "" {
return pdmResource{}, false
}
status := r.resourceStatus()
id := strings.TrimSpace(r.ID)
if id == "" {
id = remoteID + "/" + resourceType + "/" + name
}
return pdmResource{
ID: id,
RemoteID: remoteID,
Name: name,
Type: resourceType,
Status: status,
}, true
}
func (r pdmRawResource) resourceName() string {
switch r.Type {
case "pve-node":
return r.Node
case "pve-storage":
return r.Storage
default:
return r.Name
}
}
func (r pdmRawResource) resourceStatus() string {
switch r.Type {
case "pbs-node":
if r.Uptime > 0 {
return "online"
}
return "offline"
case "pbs-datastore":
if r.Maintenance == nil {
return "online"
}
return "unknown"
default:
status := strings.TrimSpace(strings.ToLower(r.Status))
if status == "" {
return "unknown"
}
return status
}
}
func pdmBridgeResourceType(resourceType string) (string, bool) {
switch resourceType {
case "pve-qemu":
return "qemu", true
case "pve-lxc":
return "lxc", true
case "pve-node", "pbs-node":
return "node", true
case "pve-storage", "pbs-datastore":
return "storage", true
}
return "", false
}
@@ -0,0 +1,185 @@
package ai
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
func TestPDMHTTPClientResourceListUsesCurrentAPIAuthAndGroupedResponse(t *testing.T) {
const (
tokenID = "root@pam!pulse"
tokenSecret = "secret-value"
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api2/extjs/resources/list" {
t.Fatalf("path: want /api2/extjs/resources/list, got %q", r.URL.Path)
}
wantAuth := "PDMAPIToken " + tokenID + ":" + tokenSecret
if got := r.Header.Get("Authorization"); got != wantAuth {
t.Fatalf("Authorization: want %q, got %q", wantAuth, got)
}
if strings.Contains(r.Header.Get("Authorization"), "PVEAPIToken") {
t.Fatal("Authorization used legacy PVE token scheme")
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"data": [
{
"remote": "pve-a",
"resources": [
{"type": "pve-qemu", "id": "remote/pve-a/guest/101", "name": "vm-101", "status": "running"},
{"type": "pve-lxc", "id": "remote/pve-a/guest/102", "name": "ct-102", "status": "stopped"},
{"type": "pve-node", "id": "remote/pve-a/node/pve1", "node": "pve1", "status": "online"},
{"type": "pve-storage", "id": "remote/pve-a/storage/local", "storage": "local", "status": "offline"},
{"type": "pve-network", "id": "remote/pve-a/network/zone/vnet1", "name": "vnet1", "status": "available"}
]
},
{
"remote": "pbs-a",
"error": "cached remote warning",
"resources": [
{"type": "pbs-node", "id": "remote/pbs-a/node/pbs1", "name": "pbs1", "uptime": 12},
{"type": "pbs-node", "id": "remote/pbs-a/node/pbs2", "name": "pbs2", "uptime": 0},
{"type": "pbs-datastore", "id": "remote/pbs-a/datastore/fast", "name": "fast", "maintenance": null},
{"type": "pbs-datastore", "id": "remote/pbs-a/datastore/archive", "name": "archive", "maintenance": "offline"}
]
}
]
}`)
}))
defer server.Close()
client, err := newPDMHTTPClient(pdmAlertBridgeConfig{
APIURL: server.URL,
APIToken: tokenID,
APITokenSecret: tokenSecret,
})
if err != nil {
t.Fatalf("newPDMHTTPClient: %v", err)
}
got, err := client.ResourceList(context.Background())
if err != nil {
t.Fatalf("ResourceList: %v", err)
}
want := []pdmResource{
{ID: "remote/pve-a/guest/101", RemoteID: "pve-a", Name: "vm-101", Type: "qemu", Status: "running"},
{ID: "remote/pve-a/guest/102", RemoteID: "pve-a", Name: "ct-102", Type: "lxc", Status: "stopped"},
{ID: "remote/pve-a/node/pve1", RemoteID: "pve-a", Name: "pve1", Type: "node", Status: "online"},
{ID: "remote/pve-a/storage/local", RemoteID: "pve-a", Name: "local", Type: "storage", Status: "offline"},
{ID: "remote/pbs-a/node/pbs1", RemoteID: "pbs-a", Name: "pbs1", Type: "node", Status: "online"},
{ID: "remote/pbs-a/node/pbs2", RemoteID: "pbs-a", Name: "pbs2", Type: "node", Status: "offline"},
{ID: "remote/pbs-a/datastore/fast", RemoteID: "pbs-a", Name: "fast", Type: "storage", Status: "online"},
{ID: "remote/pbs-a/datastore/archive", RemoteID: "pbs-a", Name: "archive", Type: "storage", Status: "unknown"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("resources:\nwant %#v\n got %#v", want, got)
}
}
func TestPDMHTTPClientNonSuccessErrorDoesNotExposeToken(t *testing.T) {
const tokenSecret = "do-not-leak"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failure", http.StatusInternalServerError)
}))
defer server.Close()
client, err := newPDMHTTPClient(pdmAlertBridgeConfig{
APIURL: server.URL,
APIToken: "root@pam!pulse",
APITokenSecret: tokenSecret,
})
if err != nil {
t.Fatalf("newPDMHTTPClient: %v", err)
}
_, err = client.ResourceList(context.Background())
if err == nil {
t.Fatal("ResourceList should fail on non-2xx status")
}
errText := err.Error()
if strings.Contains(errText, tokenSecret) || strings.Contains(errText, "Authorization") || strings.Contains(errText, "PDMAPIToken") {
t.Fatalf("error exposes auth material: %q", errText)
}
}
func TestPDMAlertBridgeConfigEnvActivation(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
t.Setenv(pdmAPIURLEnv, "")
t.Setenv(pdmAPITokenEnv, "root@pam!pulse")
t.Setenv(pdmAPITokenSecretEnv, "secret")
if got := newPDMAlertSourceFromEnv(); got != nil {
t.Fatalf("incomplete env should not activate PDM source, got %T", got)
}
t.Setenv(pdmAPIURLEnv, server.URL)
t.Setenv(pdmAPITokenEnv, "root@pam!pulse")
t.Setenv(pdmAPITokenSecretEnv, "secret")
t.Setenv(pdmInsecureSkipVerifyEnv, "true")
source := newPDMAlertSourceFromEnv()
client, ok := source.(*pdmHTTPClient)
if !ok {
t.Fatalf("complete env should activate *pdmHTTPClient, got %T", source)
}
if client.httpClient.Timeout != pdmHTTPClientRequestTimeout {
t.Fatalf("timeout: want %s, got %s", pdmHTTPClientRequestTimeout, client.httpClient.Timeout)
}
patrol := NewPatrolService(nil, nil)
if patrol.pdmAlertBridge == nil || patrol.pdmAlertBridge.source == nil {
t.Fatal("NewPatrolService should wire PDM source when complete env is present")
}
}
func TestPDMHTTPClientTLSVerificationDefaultsOn(t *testing.T) {
client, err := newPDMHTTPClient(pdmAlertBridgeConfig{
APIURL: "https://pdm.example.test",
APIToken: "root@pam!pulse",
APITokenSecret: "secret",
})
if err != nil {
t.Fatalf("newPDMHTTPClient: %v", err)
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport: want *http.Transport, got %T", client.httpClient.Transport)
}
if transport.TLSClientConfig == nil {
t.Fatal("TLSClientConfig should be set")
}
if transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("TLS verification should be enabled by default")
}
if client.httpClient.Timeout != 10*time.Second {
t.Fatalf("timeout: want 10s, got %s", client.httpClient.Timeout)
}
insecureClient, err := newPDMHTTPClient(pdmAlertBridgeConfig{
APIURL: "https://pdm.example.test",
APIToken: "root@pam!pulse",
APITokenSecret: "secret",
InsecureSkipVerify: true,
})
if err != nil {
t.Fatalf("newPDMHTTPClient insecure: %v", err)
}
insecureTransport, ok := insecureClient.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport: want *http.Transport, got %T", insecureClient.httpClient.Transport)
}
if insecureTransport.TLSClientConfig == nil || !insecureTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("PDM_INSECURE_SKIP_VERIFY=true should opt out of default TLS verification")
}
}
+3 -4
View File
@@ -466,9 +466,8 @@ type PatrolService struct {
updateSafetyWatcher *UpdateSafetyWatcher
// PDM alert bridge -- polls the PDM resource list on each patrol cycle
// and emits reliability findings for offline nodes and failed guests.
// Nil source at MVP makes Observe a no-op; the real HTTP client is a
// follow-on.
pdmAlertBridge *pdmAlertBridge
// Nil source makes Observe a no-op when PDM env configuration is absent.
pdmAlertBridge *pdmAlertBridge
// ReadState provides typed read-only views over resource state (VMs, nodes, hosts, etc.).
// This is injected separately from stateProvider since stateProvider also contains
// non-resource telemetry (alerts, backups, connection health) that isn't modeled as resources yet.
@@ -692,6 +691,6 @@ func NewPatrolService(aiService *Service, stateProvider StateProvider) *PatrolSe
p.stormThrottler = newFindingStormThrottler()
p.findings.SetStormThrottler(p.stormThrottler)
p.updateSafetyWatcher = newUpdateSafetyWatcher()
p.pdmAlertBridge = newPDMAlertBridge(nil)
p.pdmAlertBridge = newPDMAlertBridge(newPDMAlertSourceFromEnv())
return p
}