Pulse/internal/config/availability.go
2026-06-29 22:43:13 +01:00

248 lines
7.7 KiB
Go

package config
import (
"fmt"
"net"
"net/url"
"strings"
"github.com/google/uuid"
)
const (
DefaultAvailabilityPollIntervalSecs = 60
DefaultAvailabilityTimeoutMillis = 2000
DefaultAvailabilityFailureThreshold = 2
)
type AvailabilityProbeProtocol string
const (
AvailabilityProbeICMP AvailabilityProbeProtocol = "icmp"
AvailabilityProbeTCP AvailabilityProbeProtocol = "tcp"
AvailabilityProbeHTTP AvailabilityProbeProtocol = "http"
AvailabilityProbeHTTPS AvailabilityProbeProtocol = "https"
availabilityProbePingAlias AvailabilityProbeProtocol = "ping"
)
type AvailabilityTargetKind string
const (
AvailabilityTargetMachine AvailabilityTargetKind = "machine"
AvailabilityTargetService AvailabilityTargetKind = "service"
AvailabilityTargetDevice AvailabilityTargetKind = "device"
)
// AvailabilityTarget represents an agentless endpoint monitored through a
// lightweight availability probe.
type AvailabilityTarget struct {
ID string `json:"id"`
Name string `json:"name"`
TargetKind AvailabilityTargetKind `json:"targetKind,omitempty"`
Address string `json:"address"`
Protocol AvailabilityProbeProtocol `json:"protocol"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Enabled bool `json:"enabled"`
PollIntervalSecs int `json:"pollIntervalSeconds,omitempty"`
TimeoutMillis int `json:"timeoutMillis,omitempty"`
FailureThreshold int `json:"failureThreshold,omitempty"`
LinkedResourceID string `json:"linkedResourceId,omitempty"`
}
// NewAvailabilityTarget returns a new target with generated ID and defaults.
func NewAvailabilityTarget() AvailabilityTarget {
return AvailabilityTarget{
ID: uuid.NewString(),
TargetKind: AvailabilityTargetService,
Protocol: AvailabilityProbeICMP,
Enabled: true,
PollIntervalSecs: DefaultAvailabilityPollIntervalSecs,
TimeoutMillis: DefaultAvailabilityTimeoutMillis,
FailureThreshold: DefaultAvailabilityFailureThreshold,
}
}
func (t *AvailabilityTarget) ApplyDefaults() {
if t == nil {
return
}
if strings.TrimSpace(t.ID) == "" {
t.ID = uuid.NewString()
}
if strings.TrimSpace(string(t.Protocol)) == "" {
t.Protocol = AvailabilityProbeICMP
} else {
t.Protocol = normalizeAvailabilityProbeProtocol(t.Protocol)
}
if strings.TrimSpace(string(t.TargetKind)) == "" {
t.TargetKind = AvailabilityTargetService
}
if t.PollIntervalSecs <= 0 {
t.PollIntervalSecs = DefaultAvailabilityPollIntervalSecs
}
if t.TimeoutMillis <= 0 {
t.TimeoutMillis = DefaultAvailabilityTimeoutMillis
}
if t.FailureThreshold <= 0 {
t.FailureThreshold = DefaultAvailabilityFailureThreshold
}
}
func (t AvailabilityTarget) EffectivePollIntervalSecs() int {
if t.PollIntervalSecs > 0 {
return t.PollIntervalSecs
}
return DefaultAvailabilityPollIntervalSecs
}
func (t AvailabilityTarget) EffectiveTimeoutMillis() int {
if t.TimeoutMillis > 0 {
return t.TimeoutMillis
}
return DefaultAvailabilityTimeoutMillis
}
func (t AvailabilityTarget) EffectiveFailureThreshold() int {
if t.FailureThreshold > 0 {
return t.FailureThreshold
}
return DefaultAvailabilityFailureThreshold
}
func (t AvailabilityTarget) DisplayName() string {
if name := strings.TrimSpace(t.Name); name != "" {
return name
}
return strings.TrimSpace(t.Address)
}
func (t AvailabilityTarget) ProbeAddress() string {
return normalizeAvailabilityAddress(t.Address)
}
func (t AvailabilityTarget) Validate() error {
if strings.TrimSpace(t.Address) == "" {
return fmt.Errorf("availability target address is required")
}
switch t.TargetKind {
case AvailabilityTargetMachine, AvailabilityTargetService, AvailabilityTargetDevice:
default:
return fmt.Errorf("unsupported availability target kind %q", t.TargetKind)
}
protocol := normalizeAvailabilityProbeProtocol(t.Protocol)
switch protocol {
case AvailabilityProbeICMP:
if t.Port != 0 {
return fmt.Errorf("icmp availability targets must not set a port")
}
case AvailabilityProbeTCP:
if t.Port <= 0 || t.Port > 65535 {
return fmt.Errorf("tcp availability targets require a valid port")
}
case AvailabilityProbeHTTP, AvailabilityProbeHTTPS:
if t.Port < 0 || t.Port > 65535 {
return fmt.Errorf("http availability target port must be valid")
}
default:
return fmt.Errorf("unsupported availability protocol %q", t.Protocol)
}
if t.PollIntervalSecs > 0 && t.PollIntervalSecs < 10 {
return fmt.Errorf("availability poll interval must be at least 10 seconds")
}
if t.TimeoutMillis > 0 && t.TimeoutMillis < 250 {
return fmt.Errorf("availability timeout must be at least 250 milliseconds")
}
if t.FailureThreshold > 0 && t.FailureThreshold > 10 {
return fmt.Errorf("availability failure threshold must be 10 or less")
}
if protocol == AvailabilityProbeHTTP || protocol == AvailabilityProbeHTTPS {
if _, err := t.HTTPURL(); err != nil {
return err
}
return nil
}
host := t.ProbeAddress()
if host == "" {
return fmt.Errorf("availability target address is required")
}
if strings.ContainsAny(host, " \t\r\n") {
return fmt.Errorf("availability target address must not contain whitespace")
}
return nil
}
func (t AvailabilityTarget) HTTPURL() (*url.URL, error) {
raw := strings.TrimSpace(t.Address)
if raw == "" {
return nil, fmt.Errorf("availability target address is required")
}
if !strings.Contains(raw, "://") {
if t.Protocol == AvailabilityProbeHTTPS {
raw = "https://" + raw
} else {
raw = "http://" + raw
}
}
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid http availability address: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("http availability targets require http or https scheme")
}
if strings.TrimSpace(u.Hostname()) == "" {
return nil, fmt.Errorf("http availability target host is required")
}
if t.Port > 0 {
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", t.Port))
}
if path := strings.TrimSpace(t.Path); path != "" {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
u.Path = path
}
return u, nil
}
func NormalizeAvailabilityTarget(target AvailabilityTarget) AvailabilityTarget {
target.ID = strings.TrimSpace(target.ID)
target.Name = strings.TrimSpace(target.Name)
target.TargetKind = AvailabilityTargetKind(strings.ToLower(strings.TrimSpace(string(target.TargetKind))))
target.Protocol = normalizeAvailabilityProbeProtocol(target.Protocol)
if target.Protocol == AvailabilityProbeHTTP || target.Protocol == AvailabilityProbeHTTPS {
target.Address = strings.TrimSpace(target.Address)
} else {
target.Address = normalizeAvailabilityAddress(target.Address)
}
target.Path = strings.TrimSpace(target.Path)
target.LinkedResourceID = strings.TrimSpace(target.LinkedResourceID)
target.ApplyDefaults()
return target
}
func normalizeAvailabilityProbeProtocol(protocol AvailabilityProbeProtocol) AvailabilityProbeProtocol {
normalized := AvailabilityProbeProtocol(strings.ToLower(strings.TrimSpace(string(protocol))))
if normalized == availabilityProbePingAlias {
return AvailabilityProbeICMP
}
return normalized
}
func normalizeAvailabilityAddress(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, "[]")
}