Files
pulse/internal/monitoring/availability_probe_agent.go
T
rcourtman 06b7f5a0b9 Keep fixture alerts from outliving a mock-mode switch
SetMockMode(false) switched the mock package off and then cleared active
alerts, but every path that evaluates mode-dependent data checked the
mode once, up front. A mock alert pass (started from the ticker with go,
so passes overlap), a frontend or ingest refresh evaluating a GetState
snapshot, a backup-age check over fixture recovery rollups, or a
connection check over the mock ledger could already hold fixture data
when the switch happened. Its evaluations then landed after the clear,
in live mode, where nothing evaluates or resolves fixture resources, so
the alerts stayed until the 24-hour stale sweep.

A pass-level lock around checkMockAlerts was tried and reverted: it left
the other paths open and made the mock-mode API wait behind a whole pass
of synchronous event-log writes.

The monitor now owns a mock-mode epoch fence. The mock pass, backup-age
and snapshot checks, connection checks, Docker pruning and the
resource-store refresh take a scope before reading anything
mode-dependent and send each alert-manager call through it. SetMockMode,
now serialized, flips the mode, ends the epoch, waits only for calls
already admitted, and then clears. Unified metrics are evaluated one
resource per call, so the wait covers single calls, not passes.

Registry rebuilds are tracked rather than waited on. A refresh evaluates
the registry it read back only if no rebuild from an ended epoch
overlapped it, and the readers that open or remove alerts from the
inventory use current state until a clean rebuild in the new epoch
replaces the previous mode's registry. The mock supplemental fixture
sources now serve nothing outside mock mode: the router swaps them for
the real pollers only after the switch, refreshing between sources, so
a single switch to live mode used to raise fixture vSphere incidents.

The new tests park a real evaluation at a fixed point (a held lock or a
blocking supplier), switch modes and release it. Against the unfenced
code the mock pass left about 40 fixture alerts, the frontend refresh 5
vSphere incidents and the backup check 137 backup-age alerts in live
mode, and a mock-mode connection observation counted toward a live
connection's confirmation. The drain, republish and pruning tests fail
against a fence without its wait, overlap check or registry tracking.
Switching mock mode with several tenant monitors running remains
uncovered and is noted in the monitoring contract.
2026-10-07 12:03:38 +01:00

393 lines
14 KiB
Go

package monitoring
import (
"crypto/sha256"
"errors"
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/availabilityprobe"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
// availabilityProbeStaleFloor is the shortest window after which a probe-assigned
// target without a fresh report reads as indeterminate.
const availabilityProbeStaleFloor = 5 * time.Minute
// availabilityProbeStaleError is the single read-time explanation shown when an
// assigned agent stops reporting.
const availabilityProbeStaleError = "no recent report from probe agent"
// ProbeAvailabilityResult is one availability observation reported by a remote
// host agent that owns the target's execution.
type ProbeAvailabilityResult struct {
ObservationID string
TargetID string
ConfigRevision int64
Outcome availabilityprobe.Outcome
TransportOutcome availabilityprobe.Outcome
Application *availabilityprobe.ApplicationResult
LatencyMillis int64
CheckedAt time.Time
Error string
Certificate *tlsutil.CertificateObservation
}
// availabilityProbeAssignmentTracker provides a grace reference for a newly
// assigned target before its first report. AgentID is retained so reassigning
// the same target to a different probe starts a fresh grace window.
type availabilityProbeAssignmentTracker struct {
LocationID string
Since time.Time
}
// probeAvailabilityResultsFromReport converts the wire results carried by a
// host agent report. Outcome vocabulary is normalized here so the ingestion
// path never has to trust an agent's spelling; probeResultOutcome then decides
// what an unknown outcome means for failure accounting.
func probeAvailabilityResultsFromReport(reported []agentshost.AvailabilityProbeResult) []ProbeAvailabilityResult {
if len(reported) == 0 {
return nil
}
results := make([]ProbeAvailabilityResult, 0, len(reported))
for _, entry := range reported {
outcome := availabilityprobe.Outcome(strings.ToLower(strings.TrimSpace(entry.Outcome)))
switch outcome {
case availabilityprobe.OutcomeReachable, availabilityprobe.OutcomeUnreachable, availabilityprobe.OutcomeIndeterminate:
default:
outcome = availabilityprobe.OutcomeIndeterminate
}
transportOutcome := availabilityprobe.Outcome(strings.ToLower(strings.TrimSpace(entry.TransportOutcome)))
switch transportOutcome {
case availabilityprobe.OutcomeReachable, availabilityprobe.OutcomeUnreachable, availabilityprobe.OutcomeIndeterminate:
default:
transportOutcome = outcome
}
results = append(results, ProbeAvailabilityResult{
ObservationID: strings.TrimSpace(entry.ObservationID),
TargetID: strings.TrimSpace(entry.TargetID),
ConfigRevision: entry.ConfigRevision,
Outcome: outcome,
TransportOutcome: transportOutcome,
Application: applicationResultFromReport(entry),
LatencyMillis: entry.LatencyMillis,
CheckedAt: entry.CheckedAt,
Error: strings.TrimSpace(entry.Error),
Certificate: entry.Certificate.Clone(),
})
}
return results
}
// effectiveProbeAgentID returns the host agent that currently owns execution of
// the target. It collapses to local execution ("") whenever the external probe
// entitlement is absent, so a license lapse resumes local polling instead of
// stranding the check on an agent that is no longer allowed to run it.
func (m *Monitor) effectiveProbeAgentID(target config.AvailabilityTarget) string {
locations := m.effectiveObservationLocationIDs(target)
if len(locations) != 1 {
return ""
}
return config.AvailabilityObservationLocationAgentID(locations[0])
}
func (m *Monitor) effectiveObservationLocationIDs(target config.AvailabilityTarget) []string {
locations := target.EffectiveObservationLocationIDs()
if m.hasLicensedFeature(pkglicensing.FeatureExternalProbe) {
return locations
}
for _, locationID := range locations {
if locationID == config.AvailabilityObservationLocationLocal {
return []string{config.AvailabilityObservationLocationLocal}
}
}
// Preserve the historical entitlement-lapse behavior: a remote-only check
// resumes locally instead of becoming unobserved.
return []string{config.AvailabilityObservationLocationLocal}
}
func (m *Monitor) targetUsesLocalObservation(target config.AvailabilityTarget) bool {
for _, locationID := range m.effectiveObservationLocationIDs(target) {
if locationID == config.AvailabilityObservationLocationLocal {
return true
}
}
return false
}
func (m *Monitor) targetAssignedToProbeAgent(target config.AvailabilityTarget, agentID string) bool {
locationID := config.AvailabilityAgentObservationLocationID(agentID)
for _, assignedLocationID := range m.effectiveObservationLocationIDs(target) {
if assignedLocationID == locationID {
return true
}
}
return false
}
// ApplyProbeAvailabilityResults ingests availability results reported by a host
// agent. Results are accepted only for targets currently assigned to that agent.
func (m *Monitor) ApplyProbeAvailabilityResults(hostID string, results []ProbeAvailabilityResult) {
m.applyProbeAvailabilityResultsAt(hostID, results, time.Now().UTC())
}
// applyProbeAvailabilityResultsAt keeps server receipt time explicit for the
// host-report path and deterministic tests. Agent-authored CheckedAt describes
// the observation; receivedAt is the authoritative signal that the probe is
// still reporting.
func (m *Monitor) applyProbeAvailabilityResultsAt(hostID string, results []ProbeAvailabilityResult, receivedAt time.Time) {
if m == nil {
return
}
hostID = strings.TrimSpace(hostID)
if hostID == "" || len(results) == 0 {
return
}
if receivedAt.IsZero() {
receivedAt = time.Now().UTC()
} else {
receivedAt = receivedAt.UTC()
}
applied := 0
for _, result := range results {
targetID := strings.TrimSpace(result.TargetID)
if targetID == "" {
continue
}
target, ok := m.availabilityTargetByID(targetID)
if !ok {
log.Debug().
Str("hostID", hostID).
Str("targetID", targetID).
Msg("Rejecting probe availability result for unknown target")
continue
}
if !m.targetAssignedToProbeAgent(target, hostID) {
log.Debug().
Str("hostID", hostID).
Str("targetID", targetID).
Msg("Rejecting probe availability result from an agent that does not own the target")
continue
}
if result.ConfigRevision > 0 && result.ConfigRevision != target.ConfigRevision {
log.Debug().
Str("hostID", hostID).
Str("targetID", targetID).
Int64("reportedRevision", result.ConfigRevision).
Int64("currentRevision", target.ConfigRevision).
Msg("Rejecting probe availability result for an obsolete configuration revision")
continue
}
checkedAt := result.CheckedAt
if checkedAt.IsZero() {
checkedAt = receivedAt
}
latency := time.Duration(result.LatencyMillis) * time.Millisecond
if latency < 0 {
latency = 0
}
outcome, probeErr := probeResultOutcome(result)
observationID := strings.TrimSpace(result.ObservationID)
if observationID == "" {
observationID = legacyProbeAvailabilityObservationID(hostID, result)
}
m.applyAvailabilityObservationDetailed(target, observationID, checkedAt.UTC(), latency, outcome, result.TransportOutcome, result.Application, probeErr, result.Certificate, hostID, receivedAt)
applied++
}
if applied == 0 {
return
}
m.updateResourceStore(m.currentStateWithScope())
}
func applicationResultFromReport(entry agentshost.AvailabilityProbeResult) *availabilityprobe.ApplicationResult {
outcome := availabilityprobe.ApplicationOutcome(strings.ToLower(strings.TrimSpace(entry.ApplicationOutcome)))
switch outcome {
case availabilityprobe.ApplicationNotConfigured, availabilityprobe.ApplicationPassed, availabilityprobe.ApplicationFailed:
default:
return nil
}
failureCode := strings.TrimSpace(entry.ApplicationFailureCode)
switch failureCode {
case "", "status_mismatch", "response_read_failed", "response_too_large", "text_mismatch", "json_invalid", "json_path_missing", "json_value_mismatch":
default:
failureCode = ""
}
statusCode := entry.ApplicationStatusCode
if statusCode < 100 || statusCode > 599 {
statusCode = 0
}
return &availabilityprobe.ApplicationResult{Outcome: outcome, StatusCode: statusCode, FailureCode: failureCode}
}
func legacyProbeAvailabilityObservationID(hostID string, result ProbeAvailabilityResult) string {
material := fmt.Sprintf("%s\x00%s\x00%d\x00%s\x00%d\x00%s",
strings.TrimSpace(hostID), strings.TrimSpace(result.TargetID), result.CheckedAt.UTC().UnixNano(),
strings.TrimSpace(string(result.Outcome)), result.LatencyMillis, strings.TrimSpace(result.Error))
sum := sha256.Sum256([]byte(material))
return fmt.Sprintf("legacy-agent-%x", sum[:])
}
// probeResultOutcome normalizes a reported outcome and derives the failure
// signal. An unreachable report without a message still has to fail, otherwise
// remote checks would never accumulate consecutive failures.
func probeResultOutcome(result ProbeAvailabilityResult) (AvailabilityProbeOutcome, error) {
outcome := AvailabilityProbeOutcome(strings.ToLower(strings.TrimSpace(string(result.Outcome))))
message := strings.TrimSpace(result.Error)
switch outcome {
case AvailabilityProbeReachable, AvailabilityProbeUnreachable, AvailabilityProbeIndeterminate:
default:
if message != "" {
outcome = AvailabilityProbeUnreachable
} else {
outcome = AvailabilityProbeIndeterminate
}
}
if outcome == AvailabilityProbeUnreachable {
if message == "" {
message = "probe agent reported the target unreachable"
}
return outcome, errors.New(message)
}
if message != "" {
return AvailabilityProbeUnreachable, errors.New(message)
}
return outcome, nil
}
// deriveAvailabilityProbeStaleness reports the status a reader should see for a
// probe-assigned target. Stored state is never mutated: an agent that stops
// reporting must read as indeterminate without erasing its last observation.
func (m *Monitor) deriveAvailabilityProbeStaleness(
target config.AvailabilityTarget,
status AvailabilityProbeStatus,
now time.Time,
) AvailabilityProbeStatus {
agentID := strings.TrimSpace(status.ProbeAgentID)
if agentID == "" {
return status
}
status.ProbeAgentID = agentID
reference := status.FreshnessTime()
if reference.IsZero() {
reference = m.availabilityProbeAssignmentReference(target.ID, config.AvailabilityAgentObservationLocationID(agentID), now)
}
if !availabilityProbeReportIsStale(target, reference, now) {
return status
}
status.Outcome = string(AvailabilityProbeIndeterminate)
status.Available = false
status.LastError = availabilityProbeStaleError
status.LatencyMillis = 0
return status
}
func (m *Monitor) availabilityProbeAssignmentReference(targetID, locationID string, now time.Time) time.Time {
if m == nil {
return now
}
targetID = strings.TrimSpace(targetID)
locationID = strings.TrimSpace(locationID)
if targetID == "" || locationID == "" {
return now
}
trackerID := targetID + "\x00" + locationID
m.mu.Lock()
defer m.mu.Unlock()
if m.availabilityProbeTrackers == nil {
m.availabilityProbeTrackers = make(map[string]availabilityProbeAssignmentTracker)
}
if tracker, ok := m.availabilityProbeTrackers[trackerID]; ok && tracker.LocationID == locationID && !tracker.Since.IsZero() {
return tracker.Since
}
m.availabilityProbeTrackers[trackerID] = availabilityProbeAssignmentTracker{
LocationID: locationID,
Since: now,
}
return now
}
func availabilityProbeStatusIsStale(status AvailabilityProbeStatus) bool {
return status.Outcome == string(AvailabilityProbeIndeterminate) &&
strings.EqualFold(strings.TrimSpace(status.LastError), availabilityProbeStaleError)
}
func availabilityProbeReportIsStale(target config.AvailabilityTarget, lastChecked time.Time, now time.Time) bool {
if lastChecked.IsZero() {
return true
}
return now.Sub(lastChecked) > availabilityProbeStaleWindow(target)
}
func availabilityProbeStaleWindow(target config.AvailabilityTarget) time.Duration {
window := time.Duration(target.EffectivePollIntervalSecs()) * 3 * time.Second
if window < availabilityProbeStaleFloor {
return availabilityProbeStaleFloor
}
return window
}
// availabilityProbeTargetsForAgent returns the probe payload for the targets the
// given agent currently owns.
func (m *Monitor) availabilityProbeTargetsForAgent(hostID string) []map[string]interface{} {
hostID = strings.TrimSpace(hostID)
if hostID == "" {
return nil
}
var assigned []map[string]interface{}
for _, target := range m.availabilityTargets() {
if !m.targetAssignedToProbeAgent(target, hostID) {
continue
}
assigned = append(assigned, availabilityProbeAgentTargetPayload(target))
}
return assigned
}
// availabilityProbeAgentTargetPayload carries only what the agent needs to run
// the check. Failure accounting and resource linkage stay server-side.
func availabilityProbeAgentTargetPayload(target config.AvailabilityTarget) map[string]interface{} {
payload := map[string]interface{}{
"id": target.ID,
"configRevision": target.ConfigRevision,
"name": target.DisplayName(),
"targetKind": string(target.TargetKind),
"address": target.Address,
"protocol": string(target.Protocol),
"enabled": target.Enabled,
"pollIntervalSeconds": target.EffectivePollIntervalSecs(),
"timeoutMillis": target.EffectiveTimeoutMillis(),
}
if target.Port > 0 {
payload["port"] = target.Port
}
if path := strings.TrimSpace(target.Path); path != "" {
payload["path"] = path
}
if target.UDPMode != "" {
payload["udpMode"] = string(target.UDPMode)
}
if target.UDPRequest != "" {
payload["udpRequest"] = target.UDPRequest
}
if target.UDPExpected != "" {
payload["udpExpectedResponse"] = target.UDPExpected
}
if target.HTTP != nil {
// Assigned agents need the complete execution contract. These values are
// delivered only through authenticated remote config and are never echoed
// in the result/report payload.
payload["http"] = target.HTTP
}
return payload
}