Setzt die Foundation aus 1.0.70 fort — bisher war ha_nodes.config_hash
noch NULL und das UI konnte keinen Drift erkennen.
internal/cluster/confighash.go:
- ComputeConfigHash() berechnet SHA-256 (truncated auf 16 hex chars)
über alle replizierbaren Tabellen. Pattern 1:1 aus mail-gateway/
internal/handlers/cluster_status.go (driftHashSpec).
- Pro Tabelle: md5((to_jsonb(t) - id - updated_at - created_at -
excludes)::text) per row, dann string_agg ORDER BY rh.
- Singleton-Tabellen (dns_settings, ntp_settings, mail_config-Stil)
hashen direkt ohne agg.
- 23 Tabellen: domains, backends, backend_servers, routing_rules,
network_interfaces, ip_addresses, tls_certs (mit ExtraExclude
last_renewed_at + last_error damit cert-renewal keinen drift
erzeugt), firewall_zones+address_objects+address_groups+services+
service_groups+rules+nat_rules, wireguard_interfaces+peers,
forward_proxy_acls, dns_zones+records+settings, ntp_pools+settings,
static_routes.
- RefreshLocalHash() schreibt den Hash in die eigene ha_nodes-Row.
Scheduler:
- 5-min-Tick ruft RefreshLocalHash. Pro-Mutation-Refresh wäre zu
teuer (jede UI-Action triggert sonst 23 jsonb-Queries).
- Initial-Refresh beim Scheduler-Boot damit /cluster/status nicht
5 min auf den ersten Wert wartet.
handlers/cluster.go:
- Status() ruft RefreshLocalHash mit 2s-Timeout on-demand. Damit
sieht das UI auch zwischen den Scheduler-Ticks immer frische
Werte; bei Timeout fallback auf den DB-Wert (eventuell stale).
Verifiziert auf 1.0.71: ha_nodes-Row hat config_hash=728834dce5ca4e48,
scheduler-log "config-hash refresh enabled tick=5m0s".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.4 KiB
Go
119 lines
3.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
// ClusterHandler exposes cluster-state endpoints. v1 ist read-only;
|
|
// /status liefert eine strukturierte UI-Sicht (local + peers + health),
|
|
// /nodes bleibt als simpler list-endpoint für Tools/Scripts.
|
|
type ClusterHandler struct {
|
|
Store *cluster.Store
|
|
LocalID string
|
|
}
|
|
|
|
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
|
return &ClusterHandler{Store: store, LocalID: localID}
|
|
}
|
|
|
|
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
|
g := rg.Group("/cluster")
|
|
g.GET("/nodes", h.ListNodes)
|
|
g.GET("/status", h.Status)
|
|
}
|
|
|
|
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
|
nodes, err := h.Store.List(c.Request.Context())
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
response.OK(c, gin.H{"nodes": nodes, "local_id": h.LocalID})
|
|
}
|
|
|
|
// ClusterStatus ist die UI-zentrierte Sicht: local-Node hervorgehoben,
|
|
// peers separat, mode + health-flag.
|
|
type ClusterStatus struct {
|
|
LocalID string `json:"local_id"`
|
|
LocalNode *models.HANode `json:"local_node,omitempty"`
|
|
Peers []models.HANode `json:"peers"`
|
|
Mode string `json:"mode"` // "single-node" | "cluster"
|
|
Health string `json:"health"` // "ok" | "degraded" | "split-brain"
|
|
DriftFound bool `json:"drift_found"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Status splittet alle Nodes in local + peers, berechnet mode + health.
|
|
// On-demand: bevor wir die Rows lesen refreshen wir den eigenen
|
|
// config_hash, sodass das UI immer aktuelle Werte sieht — auch wenn
|
|
// der 5min-Scheduler-Tick gerade vorher nicht gelaufen ist.
|
|
func (h *ClusterHandler) Status(c *gin.Context) {
|
|
if h.Store != nil && h.Store.Pool != nil && h.LocalID != "" {
|
|
// 2s Timeout — der Hash-Compute braucht im normal-case <50ms.
|
|
// Bei timeout fallen wir auf den (eventuell stale) DB-Wert zurück.
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
|
if _, err := cluster.RefreshLocalHash(ctx, h.Store.Pool, h.LocalID); err != nil {
|
|
slog.Warn("cluster: config_hash refresh failed", "error", err)
|
|
}
|
|
cancel()
|
|
}
|
|
all, err := h.Store.List(c.Request.Context())
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
out := ClusterStatus{
|
|
LocalID: h.LocalID,
|
|
Peers: []models.HANode{},
|
|
Mode: "single-node",
|
|
Health: "ok",
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
var localHash *string
|
|
for i := range all {
|
|
n := all[i]
|
|
if n.ID == h.LocalID {
|
|
ln := n
|
|
out.LocalNode = &ln
|
|
localHash = ln.ConfigHash
|
|
continue
|
|
}
|
|
out.Peers = append(out.Peers, n)
|
|
}
|
|
if len(out.Peers) > 0 {
|
|
out.Mode = "cluster"
|
|
}
|
|
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
|
// lokaler → Banner-Trigger im UI.
|
|
if localHash != nil && *localHash != "" {
|
|
for _, p := range out.Peers {
|
|
if p.ConfigHash == nil || *p.ConfigHash == "" {
|
|
continue
|
|
}
|
|
if *p.ConfigHash != *localHash {
|
|
out.DriftFound = true
|
|
out.Health = "degraded"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Offline-Peers → degraded.
|
|
if !out.DriftFound {
|
|
for _, p := range out.Peers {
|
|
if p.Status != "online" {
|
|
out.Health = "degraded"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
response.OK(c, out)
|
|
}
|