mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
460 lines
15 KiB
Go
460 lines
15 KiB
Go
package mock
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
const (
|
|
mockAvailabilityProbeICMP = "icmp"
|
|
mockAvailabilityProbeTCP = "tcp"
|
|
mockAvailabilityProbeHTTP = "http"
|
|
|
|
mockAvailabilityTargetMachine = "machine"
|
|
mockAvailabilityTargetService = "service"
|
|
mockAvailabilityTargetDevice = "device"
|
|
|
|
mockAvailabilityDefaultPollIntervalSecs = 60
|
|
mockAvailabilityDefaultTimeoutMillis = 2000
|
|
mockAvailabilityDefaultFailureThreshold = 2
|
|
)
|
|
|
|
// AvailabilityTargetFixture is the config-free fixture contract for an
|
|
// agentless availability endpoint. The API layer converts it into
|
|
// config.AvailabilityTarget when serving configuration-shaped responses.
|
|
type AvailabilityTargetFixture struct {
|
|
ID string
|
|
Name string
|
|
TargetKind string
|
|
LinkedResourceID string
|
|
Address string
|
|
Protocol string
|
|
Port int
|
|
Path string
|
|
Enabled bool
|
|
PollIntervalSecs int
|
|
TimeoutMillis int
|
|
FailureThreshold int
|
|
}
|
|
|
|
// AvailabilityFixture describes an agentless mock endpoint that represents
|
|
// infrastructure Pulse cannot manage through an API or installed agent.
|
|
type AvailabilityFixture struct {
|
|
Target AvailabilityTargetFixture
|
|
Available bool
|
|
LastChecked time.Time
|
|
LastSuccess time.Time
|
|
LatencyMillis int64
|
|
ConsecutiveFailures int
|
|
LastError string
|
|
}
|
|
|
|
func AvailabilityFixtures() []AvailabilityFixture {
|
|
if !IsMockEnabled() {
|
|
return nil
|
|
}
|
|
return CurrentFixtureGraph().AvailabilityFixtures
|
|
}
|
|
|
|
func normalizeAvailabilityTargetFixture(target AvailabilityTargetFixture) AvailabilityTargetFixture {
|
|
target.ID = strings.TrimSpace(target.ID)
|
|
target.Name = strings.TrimSpace(target.Name)
|
|
target.TargetKind = strings.ToLower(strings.TrimSpace(target.TargetKind))
|
|
if target.TargetKind == "" {
|
|
target.TargetKind = mockAvailabilityTargetService
|
|
}
|
|
target.LinkedResourceID = strings.TrimSpace(target.LinkedResourceID)
|
|
target.Protocol = strings.ToLower(strings.TrimSpace(target.Protocol))
|
|
if target.Protocol == "" {
|
|
target.Protocol = mockAvailabilityProbeICMP
|
|
}
|
|
if target.Protocol == mockAvailabilityProbeHTTP {
|
|
target.Address = strings.TrimSpace(target.Address)
|
|
} else {
|
|
target.Address = normalizeAvailabilityFixtureAddress(target.Address)
|
|
}
|
|
target.Path = strings.TrimSpace(target.Path)
|
|
if target.PollIntervalSecs <= 0 {
|
|
target.PollIntervalSecs = mockAvailabilityDefaultPollIntervalSecs
|
|
}
|
|
if target.TimeoutMillis <= 0 {
|
|
target.TimeoutMillis = mockAvailabilityDefaultTimeoutMillis
|
|
}
|
|
if target.FailureThreshold <= 0 {
|
|
target.FailureThreshold = mockAvailabilityDefaultFailureThreshold
|
|
}
|
|
return target
|
|
}
|
|
|
|
func (t AvailabilityTargetFixture) displayName() string {
|
|
if name := strings.TrimSpace(t.Name); name != "" {
|
|
return name
|
|
}
|
|
return strings.TrimSpace(t.Address)
|
|
}
|
|
|
|
func (t AvailabilityTargetFixture) probeAddress() string {
|
|
return normalizeAvailabilityFixtureAddress(t.Address)
|
|
}
|
|
|
|
func (t AvailabilityTargetFixture) effectivePollIntervalSecs() int {
|
|
if t.PollIntervalSecs > 0 {
|
|
return t.PollIntervalSecs
|
|
}
|
|
return mockAvailabilityDefaultPollIntervalSecs
|
|
}
|
|
|
|
func (t AvailabilityTargetFixture) effectiveTimeoutMillis() int {
|
|
if t.TimeoutMillis > 0 {
|
|
return t.TimeoutMillis
|
|
}
|
|
return mockAvailabilityDefaultTimeoutMillis
|
|
}
|
|
|
|
func (t AvailabilityTargetFixture) effectiveFailureThreshold() int {
|
|
if t.FailureThreshold > 0 {
|
|
return t.FailureThreshold
|
|
}
|
|
return mockAvailabilityDefaultFailureThreshold
|
|
}
|
|
|
|
func normalizeAvailabilityFixtureAddress(raw string) string {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(value, "://") {
|
|
if u, err := url.Parse(value); err == nil && strings.TrimSpace(u.Hostname()) != "" {
|
|
return strings.TrimSpace(u.Hostname())
|
|
}
|
|
}
|
|
if host, _, err := net.SplitHostPort(value); err == nil && strings.TrimSpace(host) != "" {
|
|
return strings.Trim(strings.TrimSpace(host), "[]")
|
|
}
|
|
return strings.Trim(value, "[]")
|
|
}
|
|
|
|
func defaultAvailabilityFixtures(now time.Time) []AvailabilityFixture {
|
|
base := normalizeAvailabilityFixtureTime(now)
|
|
return []AvailabilityFixture{
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-docker-frontend-service",
|
|
Name: "Frontend Swarm service",
|
|
TargetKind: mockAvailabilityTargetService,
|
|
LinkedResourceID: "mock-swarm-cluster-1:service:svc-frontend-0",
|
|
Address: "https://frontend.demo.pulse.local",
|
|
Protocol: mockAvailabilityProbeHTTP,
|
|
Path: "/health",
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1500,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: true,
|
|
LastChecked: base.Add(-9 * time.Second),
|
|
LastSuccess: base.Add(-9 * time.Second),
|
|
LatencyMillis: 9,
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-k8s-checkout-api",
|
|
Name: "Checkout API service",
|
|
TargetKind: mockAvailabilityTargetService,
|
|
LinkedResourceID: "k8s-production-1:service:k8s-production-1-svc-checkout-api",
|
|
Address: "checkout-api.services.svc.cluster.local",
|
|
Protocol: mockAvailabilityProbeTCP,
|
|
Port: 8080,
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1500,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: true,
|
|
LastChecked: base.Add(-6 * time.Second),
|
|
LastSuccess: base.Add(-6 * time.Second),
|
|
LatencyMillis: 5,
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-ups",
|
|
Name: "Rack UPS network card",
|
|
TargetKind: mockAvailabilityTargetDevice,
|
|
Address: "ups-rack-a.lab.local",
|
|
Protocol: mockAvailabilityProbeICMP,
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1000,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: true,
|
|
LastChecked: base.Add(-8 * time.Second),
|
|
LastSuccess: base.Add(-8 * time.Second),
|
|
LatencyMillis: 3,
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-mqtt-meter",
|
|
Name: "MQTT power meter",
|
|
TargetKind: mockAvailabilityTargetDevice,
|
|
Address: "power-meter-01.lab.local",
|
|
Protocol: mockAvailabilityProbeTCP,
|
|
Port: 1883,
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1500,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: true,
|
|
LastChecked: base.Add(-14 * time.Second),
|
|
LastSuccess: base.Add(-14 * time.Second),
|
|
LatencyMillis: 7,
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-esphome-greenhouse",
|
|
Name: "ESPHome greenhouse sensor",
|
|
TargetKind: mockAvailabilityTargetDevice,
|
|
Address: "greenhouse-sensor.lab.local",
|
|
Protocol: mockAvailabilityProbeTCP,
|
|
Port: 6053,
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1500,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: true,
|
|
LastChecked: base.Add(-5 * time.Second),
|
|
LastSuccess: base.Add(-5 * time.Second),
|
|
LatencyMillis: 11,
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-solar-inverter",
|
|
Name: "Solar inverter web panel",
|
|
TargetKind: mockAvailabilityTargetDevice,
|
|
Address: "http://solar-inverter.lab.local/",
|
|
Protocol: mockAvailabilityProbeHTTP,
|
|
Path: "/status",
|
|
Enabled: true,
|
|
PollIntervalSecs: 60,
|
|
TimeoutMillis: 2000,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: false,
|
|
LastChecked: base.Add(-18 * time.Second),
|
|
LastSuccess: base.Add(-3 * time.Minute),
|
|
ConsecutiveFailures: 1,
|
|
LastError: "http probe returned 503 Service Unavailable",
|
|
},
|
|
{
|
|
Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{
|
|
ID: "mock-availability-door-controller",
|
|
Name: "Workshop door controller",
|
|
TargetKind: mockAvailabilityTargetDevice,
|
|
Address: "10.24.40.45",
|
|
Protocol: mockAvailabilityProbeICMP,
|
|
Enabled: true,
|
|
PollIntervalSecs: 30,
|
|
TimeoutMillis: 1000,
|
|
FailureThreshold: 2,
|
|
}),
|
|
Available: false,
|
|
LastChecked: base.Add(-11 * time.Second),
|
|
LastSuccess: base.Add(-9 * time.Minute),
|
|
ConsecutiveFailures: 3,
|
|
LastError: "icmp probe timed out",
|
|
},
|
|
}
|
|
}
|
|
|
|
func cloneAvailabilityFixtures(in []AvailabilityFixture) []AvailabilityFixture {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make([]AvailabilityFixture, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func rebaseAvailabilityFixtures(fixtures []AvailabilityFixture, now time.Time) []AvailabilityFixture {
|
|
target := normalizeAvailabilityFixtureTime(now)
|
|
if len(fixtures) == 0 {
|
|
return defaultAvailabilityFixtures(target)
|
|
}
|
|
out := cloneAvailabilityFixtures(fixtures)
|
|
anchor := availabilityFixturesFreshness(fixtures)
|
|
if anchor.IsZero() {
|
|
anchor = target
|
|
}
|
|
shift := target.Sub(anchor)
|
|
for i := range out {
|
|
out[i].Target = normalizeAvailabilityTargetFixture(out[i].Target)
|
|
out[i].LastChecked = shiftTime(fixtures[i].LastChecked, shift, target)
|
|
out[i].LastSuccess = shiftTime(fixtures[i].LastSuccess, shift, target)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func availabilityFixtureRecords(fixtures []AvailabilityFixture, now time.Time) []unifiedresources.IngestRecord {
|
|
if len(fixtures) == 0 {
|
|
return nil
|
|
}
|
|
target := normalizeAvailabilityFixtureTime(now)
|
|
out := make([]unifiedresources.IngestRecord, 0, len(fixtures))
|
|
for _, fixture := range fixtures {
|
|
record, ok := availabilityFixtureRecord(fixture, target)
|
|
if ok {
|
|
out = append(out, record)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func availabilityFixtureRecord(fixture AvailabilityFixture, now time.Time) (unifiedresources.IngestRecord, bool) {
|
|
target := normalizeAvailabilityTargetFixture(fixture.Target)
|
|
if strings.TrimSpace(target.ID) == "" {
|
|
return unifiedresources.IngestRecord{}, false
|
|
}
|
|
lastSeen := fixture.LastChecked
|
|
if lastSeen.IsZero() {
|
|
lastSeen = now
|
|
}
|
|
data := &unifiedresources.AvailabilityData{
|
|
TargetID: target.ID,
|
|
LinkedResourceID: target.LinkedResourceID,
|
|
Name: target.displayName(),
|
|
TargetKind: target.TargetKind,
|
|
Address: target.Address,
|
|
Protocol: string(target.Protocol),
|
|
Port: target.Port,
|
|
Path: target.Path,
|
|
Enabled: target.Enabled,
|
|
Available: fixture.Available,
|
|
LastChecked: fixture.LastChecked,
|
|
LastSuccess: fixture.LastSuccess,
|
|
LatencyMillis: fixture.LatencyMillis,
|
|
ConsecutiveFailures: fixture.ConsecutiveFailures,
|
|
LastError: fixture.LastError,
|
|
FailureThreshold: target.effectiveFailureThreshold(),
|
|
PollIntervalSeconds: target.effectivePollIntervalSecs(),
|
|
TimeoutMillis: target.effectiveTimeoutMillis(),
|
|
}
|
|
resource := unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeNetworkEndpoint,
|
|
Technology: string(target.Protocol),
|
|
Name: target.displayName(),
|
|
Status: availabilityFixtureResourceStatus(target, fixture),
|
|
LastSeen: lastSeen,
|
|
UpdatedAt: now,
|
|
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
|
|
Tags: availabilityFixtureTags(target),
|
|
Availability: data,
|
|
}
|
|
if incident := availabilityFixtureIncident(target, fixture, lastSeen); incident != nil {
|
|
resource.Incidents = []unifiedresources.ResourceIncident{*incident}
|
|
}
|
|
|
|
return unifiedresources.IngestRecord{
|
|
SourceID: target.ID,
|
|
Resource: resource,
|
|
Identity: availabilityFixtureIdentity(target),
|
|
}, true
|
|
}
|
|
|
|
func availabilityFixtureResourceStatus(target AvailabilityTargetFixture, fixture AvailabilityFixture) unifiedresources.ResourceStatus {
|
|
if !target.Enabled {
|
|
return unifiedresources.StatusUnknown
|
|
}
|
|
if fixture.LastChecked.IsZero() {
|
|
return unifiedresources.StatusUnknown
|
|
}
|
|
if fixture.Available {
|
|
return unifiedresources.StatusOnline
|
|
}
|
|
if fixture.ConsecutiveFailures >= target.effectiveFailureThreshold() {
|
|
return unifiedresources.StatusOffline
|
|
}
|
|
return unifiedresources.StatusWarning
|
|
}
|
|
|
|
func availabilityFixtureIncident(target AvailabilityTargetFixture, fixture AvailabilityFixture, startedAt time.Time) *unifiedresources.ResourceIncident {
|
|
if !target.Enabled || fixture.Available || fixture.LastChecked.IsZero() {
|
|
return nil
|
|
}
|
|
if fixture.ConsecutiveFailures < target.effectiveFailureThreshold() {
|
|
return nil
|
|
}
|
|
summary := fmt.Sprintf("%s is unreachable by %s probe", target.displayName(), strings.ToUpper(target.Protocol))
|
|
if strings.TrimSpace(fixture.LastError) != "" {
|
|
summary += ": " + fixture.LastError
|
|
}
|
|
return &unifiedresources.ResourceIncident{
|
|
Provider: string(unifiedresources.SourceAvailability),
|
|
NativeID: target.ID,
|
|
Code: "availability_unreachable",
|
|
Severity: storagehealth.RiskCritical,
|
|
Source: string(unifiedresources.SourceAvailability),
|
|
Summary: summary,
|
|
StartedAt: startedAt,
|
|
}
|
|
}
|
|
|
|
func availabilityFixtureIdentity(target AvailabilityTargetFixture) unifiedresources.ResourceIdentity {
|
|
identity := unifiedresources.ResourceIdentity{}
|
|
if ip := net.ParseIP(target.probeAddress()); ip != nil {
|
|
identity.IPAddresses = []string{ip.String()}
|
|
return identity
|
|
}
|
|
if host := target.probeAddress(); host != "" {
|
|
identity.Hostnames = []string{host}
|
|
}
|
|
return identity
|
|
}
|
|
|
|
func availabilityFixtureTags(target AvailabilityTargetFixture) []string {
|
|
tags := []string{"agentless", "no-agent"}
|
|
if target.TargetKind != "" {
|
|
tags = append(tags, target.TargetKind)
|
|
}
|
|
switch target.Protocol {
|
|
case mockAvailabilityProbeHTTP:
|
|
tags = append(tags, "web-interface")
|
|
case mockAvailabilityProbeTCP:
|
|
if target.Port == 1883 {
|
|
tags = append(tags, "mqtt")
|
|
}
|
|
if target.Port == 6053 || strings.Contains(strings.ToLower(target.displayName()), "esphome") {
|
|
tags = append(tags, "esphome")
|
|
}
|
|
}
|
|
return tags
|
|
}
|
|
|
|
func availabilityFixturesFreshness(fixtures []AvailabilityFixture) time.Time {
|
|
var freshness time.Time
|
|
for _, fixture := range fixtures {
|
|
for _, candidate := range []time.Time{fixture.LastChecked, fixture.LastSuccess} {
|
|
if candidate.IsZero() {
|
|
continue
|
|
}
|
|
if freshness.IsZero() || candidate.After(freshness) {
|
|
freshness = candidate
|
|
}
|
|
}
|
|
}
|
|
return freshness
|
|
}
|
|
|
|
func normalizeAvailabilityFixtureTime(value time.Time) time.Time {
|
|
if value.IsZero() {
|
|
return time.Now().UTC()
|
|
}
|
|
return value.UTC()
|
|
}
|