mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Fix telemetry sender restarts and dual-stack health probes
An unchanged telemetry preference save restarted the sender and emitted a new startup event. Invoke its callback only after a persisted boolean transition so unrelated and repeated settings saves preserve its lifecycle. An IPv6 wildcard listener can serve IPv4 while IPv6 loopback is disabled. Inspect the bound socket mode and use IPv4 loopback for dual-stack sockets while retaining IPv6 loopback for IPv6-only listeners. Pin both regressions with persisted-settings and HTTP/HTTPS listener tests. Keep the telemetry observation limits in the owning subsystem contracts.
This commit is contained in:
parent
fa1d9eb36d
commit
f50c4d6e3b
11 changed files with 179 additions and 3 deletions
|
|
@ -3642,6 +3642,14 @@ fields. No agent-lifecycle behavior keyed off them — agent update targeting
|
|||
and command admission are unaffected — and the extension-point expectations
|
||||
on the system-settings boundary are otherwise unchanged.
|
||||
|
||||
### Shared telemetry settings preserve sender lifecycle ownership
|
||||
|
||||
Telemetry preference saves invoke the sender callback only after a persisted
|
||||
effective boolean transition. Repeated saves do not restart that sender.
|
||||
This lifecycle belongs to telemetry reporting, not agent registration or
|
||||
command admission. A telemetry `startup` event also follows re-enabling the
|
||||
sender and therefore cannot serve as a count of agent or server restarts.
|
||||
|
||||
### Shared system-settings boundary gained an SSH backoff reset side effect
|
||||
|
||||
The shared `internal/api` system-settings surface this subsystem consumes
|
||||
|
|
|
|||
|
|
@ -4729,6 +4729,14 @@ ignores them without a validation error and never writes them back into
|
|||
`internal/api/system_settings_telemetry_test.go` and the response snapshot in
|
||||
`internal/api/contract_test.go` pin that payload shape.
|
||||
|
||||
### Telemetry preference saves preserve the sender lifecycle
|
||||
|
||||
The telemetry preference callback is change-driven after durable persistence.
|
||||
Resubmitting the current `telemetryEnabled` boolean saves the preference
|
||||
without restarting or stopping its sender. Actual disable/re-enable transitions
|
||||
still invoke the live callback. `TestTelemetryUpdate_OnlyPreferenceTransitionsToggle`
|
||||
pins repeated saves, both transitions, and the persisted boolean.
|
||||
|
||||
### System settings save clears the temperature SSH failure backoff
|
||||
|
||||
A successful `POST /api/system-settings` save now also calls
|
||||
|
|
|
|||
|
|
@ -1502,6 +1502,15 @@ error, account, customer, or infrastructure identity may enter the payload or
|
|||
persisted receiver row. The previous-release fields are direct adjacent-release
|
||||
observations, not 30-day update counters, and are the only valid basis for a
|
||||
before/after release-health cohort in the adoption report.
|
||||
For an unspecified IPv6 TCP listener, the probe inspects the bound socket's
|
||||
IPv6-only option. A dual-stack socket uses IPv4 loopback, including when IPv6
|
||||
is disabled on loopback, while an IPv6-only socket retains IPv6 loopback.
|
||||
Saving an unchanged effective `telemetryEnabled` value must not restart the
|
||||
sender. Only a persisted preference transition invokes its live toggle.
|
||||
The `startup` event also follows a genuine telemetry re-enable, so its count
|
||||
alone does not establish process restarts. A degraded active-alert persistence
|
||||
gauge establishes a recovery marker, not its filesystem or database cause.
|
||||
Neither anonymous signal identifies a customer or proves a release regression.
|
||||
That same outbound usage telemetry floor now also permits only content-free Pulse
|
||||
Patrol control and governed Pulse Intelligence operations adoption flags and
|
||||
counters inside the same rotating 30-day telemetry window:
|
||||
|
|
|
|||
|
|
@ -2778,6 +2778,15 @@ fields. Persisted `system.json` files that still carry the legacy keys load
|
|||
cleanly with the keys ignored, so tenant workspace preservation and recovery
|
||||
flows that copy `system.json` forward are unaffected.
|
||||
|
||||
### Shared telemetry preference updates preserve recovery ownership
|
||||
|
||||
Telemetry preference updates remain durable before the sender callback.
|
||||
Resubmitting the current effective boolean persists the preference without
|
||||
restarting the sender. The settings document and recovery ownership retain
|
||||
their existing shapes. A degraded active-alert persistence gauge separately
|
||||
records the presence of its recovery marker and must not be interpreted as a
|
||||
specific disk, database, or migration diagnosis without local evidence.
|
||||
|
||||
### Shared system-settings boundary gained an SSH backoff reset side effect
|
||||
|
||||
The shared `internal/api` system-settings surface this subsystem consumes
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ type SystemSettingsHandler struct {
|
|||
// runtime, so the router can reconfigure the monitor's checker and
|
||||
// collector without a restart.
|
||||
guestDockerInventoryToggleFunc func()
|
||||
mtMonitor interface {
|
||||
mtMonitor interface {
|
||||
GetMonitor(string) (*monitoring.Monitor, error)
|
||||
}
|
||||
defaultMonitor SystemSettingsMonitor
|
||||
|
|
@ -1069,7 +1069,8 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
|||
Bool("enabled", settings.EnableProxmoxGuestDockerInventory).
|
||||
Msg("Proxmox guest Docker inventory opt-in changed via settings")
|
||||
}
|
||||
if _, ok := rawRequest["telemetryEnabled"]; ok && settings.TelemetryEnabled != nil {
|
||||
if _, ok := rawRequest["telemetryEnabled"]; ok && settings.TelemetryEnabled != nil &&
|
||||
h.config.TelemetryEnabled != *settings.TelemetryEnabled {
|
||||
h.config.TelemetryEnabled = *settings.TelemetryEnabled
|
||||
if h.telemetryToggleFunc != nil {
|
||||
h.telemetryToggleFunc(*settings.TelemetryEnabled)
|
||||
|
|
|
|||
|
|
@ -403,6 +403,43 @@ func TestTelemetryUpdate_NoMutationOnPersistFailure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTelemetryUpdate_OnlyPreferenceTransitionsToggle(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
DataPath: tempDir, ConfigPath: tempDir, TelemetryEnabled: true,
|
||||
EnvOverrides: make(map[string]bool),
|
||||
}
|
||||
handler, persistence, token := setupTelemetryTest(t, cfg)
|
||||
settings := config.DefaultSystemSettings()
|
||||
enabled := true
|
||||
settings.TelemetryEnabled = &enabled
|
||||
if err := persistence.SaveSystemSettings(*settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var toggles []bool
|
||||
handler.SetTelemetryToggleFunc(func(value bool) { toggles = append(toggles, value) })
|
||||
for i, value := range []bool{true, true, false, false, true, true} {
|
||||
body, err := json.Marshal(map[string]interface{}{"telemetryEnabled": value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
|
||||
req.Header.Set("X-API-Token", token)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateSystemSettings(rec, req)
|
||||
if rec.Code != http.StatusOK || cfg.TelemetryEnabled != value {
|
||||
t.Fatalf("save %d returned %d, enabled=%v: %s", i, rec.Code, cfg.TelemetryEnabled, rec.Body.String())
|
||||
}
|
||||
persisted, err := persistence.LoadSystemSettings()
|
||||
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled != value {
|
||||
t.Fatalf("save %d did not preserve requested preference: settings=%#v error=%v", i, persisted, err)
|
||||
}
|
||||
}
|
||||
if len(toggles) != 2 || toggles[0] != false || toggles[1] != true {
|
||||
t.Fatalf("runtime toggles = %v, want only the disable and re-enable transitions", toggles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetryUpdate_UnrelatedUpdateDoesNotToggle(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
|
||||
|
|
@ -99,7 +100,7 @@ func localServiceHealthBaseURL(listener net.Listener, tlsEnabled bool) (string,
|
|||
}
|
||||
ip := tcpAddr.IP
|
||||
if ip == nil || ip.IsUnspecified() {
|
||||
if ip != nil && ip.To4() == nil {
|
||||
if ip != nil && ip.To4() == nil && !serviceHealthListenerAcceptsIPv4(listener) {
|
||||
ip = net.IPv6loopback
|
||||
} else {
|
||||
ip = net.IPv4(127, 0, 0, 1)
|
||||
|
|
@ -112,6 +113,28 @@ func localServiceHealthBaseURL(listener net.Listener, tlsEnabled bool) (string,
|
|||
return fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip.String(), fmt.Sprintf("%d", tcpAddr.Port))), true
|
||||
}
|
||||
|
||||
// An IPv6 wildcard TCP listener can also serve IPv4. Prefer IPv4 loopback
|
||||
// when its socket actually accepts it: IPv6 can be disabled on loopback even
|
||||
// while this listener serves the UI/API normally over IPv4. Keep IPv6-only
|
||||
// listeners on their own address rather than probing another socket's port.
|
||||
func serviceHealthListenerAcceptsIPv4(listener net.Listener) bool {
|
||||
socket, ok := listener.(syscall.Conn)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
raw, err := socket.SyscallConn()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
dualStack := false
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
dualStack = serviceHealthSocketIsDualStack(fd)
|
||||
}); err != nil {
|
||||
return false
|
||||
}
|
||||
return dualStack
|
||||
}
|
||||
|
||||
func frontendAssetPaths(index []byte) []string {
|
||||
matches := frontendAssetReferencePattern.FindAllSubmatch(index, serviceHealthAssetLimit)
|
||||
paths := make([]string, 0, len(matches))
|
||||
|
|
|
|||
7
pkg/server/service_health_socket_other.go
Normal file
7
pkg/server/service_health_socket_other.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !unix && !windows
|
||||
|
||||
package server
|
||||
|
||||
func serviceHealthSocketIsDualStack(_ uintptr) bool {
|
||||
return false
|
||||
}
|
||||
10
pkg/server/service_health_socket_unix.go
Normal file
10
pkg/server/service_health_socket_unix.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build unix
|
||||
|
||||
package server
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func serviceHealthSocketIsDualStack(fd uintptr) bool {
|
||||
v6Only, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY)
|
||||
return err == nil && v6Only == 0
|
||||
}
|
||||
10
pkg/server/service_health_socket_windows.go
Normal file
10
pkg/server/service_health_socket_windows.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func serviceHealthSocketIsDualStack(fd uintptr) bool {
|
||||
v6Only, err := windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY)
|
||||
return err == nil && v6Only == 0
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -14,6 +16,58 @@ import (
|
|||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func TestServiceHealthProbeHandlesWildcardListeners(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, network, address, wantHost string
|
||||
}{
|
||||
{"ipv4", "tcp4", "0.0.0.0:0", "127.0.0.1"},
|
||||
{"dual-stack", "tcp", "[::]:0", "127.0.0.1"},
|
||||
{"ipv6-only", "tcp6", "[::]:0", "::1"},
|
||||
} {
|
||||
for _, tlsEnabled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/tls=%v", test.name, tlsEnabled), func(t *testing.T) {
|
||||
listener, err := net.Listen(test.network, test.address)
|
||||
if err != nil {
|
||||
if test.network != "tcp4" {
|
||||
t.Skipf("IPv6 listener unavailable: %v", err)
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/health":
|
||||
fmt.Fprint(w, `{"status":"healthy"}`)
|
||||
case "/":
|
||||
fmt.Fprint(w, `<html><script src="/assets/app.js"></script></html>`)
|
||||
case "/assets/app.js":
|
||||
fmt.Fprint(w, "console.log('ok')")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
_ = server.Listener.Close()
|
||||
server.Listener = listener
|
||||
if tlsEnabled {
|
||||
server.StartTLS()
|
||||
} else {
|
||||
server.Start()
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
baseURL, ok := localServiceHealthBaseURL(listener, tlsEnabled)
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if !ok || err != nil || parsed.Hostname() != test.wantHost {
|
||||
t.Fatalf("probe target = %q, want bound listener reachable through %s", baseURL, test.wantHost)
|
||||
}
|
||||
got := newServiceHealthProbe(listener, tlsEnabled)()
|
||||
if !got.Observed || !got.Healthy || got.FailureCategory != "" {
|
||||
t.Fatalf("reachable listener reported unhealthy: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceHealthProbeCoversAPIUIAndFrontendAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue