Files
uptop/internal/cluster/cluster.go
T
lerko f023e38fdc refactor(monitor): encapsulate engine state, add graceful shutdown and tests
Replace all monitor package-level mutable state with Engine struct.
All state (liveState, logStore, histories, tokenIndex, HTTP clients)
is now encapsulated in Engine, created via NewEngine(store).

Key changes:
- Engine struct holds all monitor state with proper mutex protection
- Engine.Start(ctx) and monitorRoutine respect context cancellation
  for graceful shutdown — no more leaked goroutines
- cluster.runFollowerLoop also respects context for clean exit
- Token index (map[string]int) for O(1) push heartbeat lookup,
  replacing O(n) linear scan through LiveState
- UpdateSiteConfig preserves 8 runtime fields instead of copying
  17 config fields individually
- triggerAlert goroutines get 30s timeout context
- All consumers (TUI, server, cluster, main) receive *Engine via
  constructor/parameter — no package-level state access
- main.go creates context.WithCancel, passes to engine and cluster

First test suite: 12 tests across store and alert packages
- Store: CRUD for sites/alerts/users, push token generation,
  import/export round-trip, check history persistence
- Alert: Discord/Slack/Webhook payload format, HTTP 4xx error
  propagation, Ntfy headers, unknown provider returns nil
2026-05-15 08:21:17 -04:00

78 lines
1.8 KiB
Go

package cluster
import (
"context"
"fmt"
"go-upkeep/internal/monitor"
"net/http"
"strings"
"time"
)
type Config struct {
Mode string // "leader" or "follower"
PeerURL string // URL of the Leader (e.g., http://primary:8080)
SharedKey string // Security Key
}
func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
if cfg.Mode == "leader" {
fmt.Println("Cluster: Running as LEADER (Active)")
if cfg.SharedKey != "" {
fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.")
}
eng.SetActive(true)
return
}
if cfg.Mode == "follower" {
fmt.Println("Cluster: Running as FOLLOWER (Passive)")
if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") {
fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.")
}
eng.SetActive(false)
go runFollowerLoop(ctx, cfg, eng)
}
}
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
client := http.Client{Timeout: 2 * time.Second}
failures := 0
threshold := 3
for {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
if cfg.SharedKey != "" {
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
}
resp, err := client.Do(req)
isLeaderHealthy := false
if err == nil && resp.StatusCode == 200 {
isLeaderHealthy = true
resp.Body.Close()
}
if isLeaderHealthy {
failures = 0
if eng.IsActive() {
eng.SetActive(false)
eng.AddLog("Cluster: Leader detected. Switching to PASSIVE.")
}
} else {
failures++
if failures >= threshold && !eng.IsActive() {
eng.SetActive(true)
eng.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.")
}
}
}
}