Files
edgeguard-native/internal/handlers/cluster.go
Debian ea7c356455 feat(cluster): Phase 3 Foundation — node.conf + ha_nodes-Drift + UI
Code-Vorbereitung für Multi-Node, ohne dass eine zweite Box nötig ist.
Single-Node-Mode bleibt der Default; alles existiert und wird sichtbar,
sobald ein 2. Knoten joined (Phase 3.2 später).

Migration 0020:
  ha_nodes += version (edgeguard-api-Version)
              config_hash (drift-Detection-Hash)
              mgmt_ip (Management-IP, niemals VIP)
              status (online|offline|joining|leaving|unknown)

internal/cluster/local_config.go:
  /etc/edgeguard/node.conf — INI-style, node-lokale Identität:
  NODE_ID, HOSTNAME, MGMT_IP, ROLE, PEER_HOSTS. NIEMALS zwischen
  Cluster-Peers replizieren. LoadLocalConfig / SaveLocalConfig /
  EnsureLocalConfig (auto-Generierung beim ersten Boot).
  MgmtIP-Default = firstNonLoopbackIPv4(); Operator kann
  überschreiben (mehrere Interfaces).

internal/cluster/store.go:
  - HANode-Model um die 4 neuen Felder erweitert
  - UpsertSelf nimmt jetzt mgmt_ip/version/config_hash/status, COALESCE
    erhält werte wenn der Caller sie nicht setzt
  - EnsureSelfRegistered-Signatur: + role + version-Argument

internal/handlers/cluster.go:
  GET /api/v1/cluster/status — strukturierter Endpoint:
    {local_id, local_node, peers[], mode, health, drift_found, updated_at}
  GET /api/v1/cluster/nodes bleibt für Tools.

UI (pages/Cluster):
  - Header zeigt Mode-Tag (Single-Node / Cluster) + Health-Tag (OK /
    degraded / split-brain)
  - Self-Card: Descriptions mit FQDN, Node-ID, Status, Role, Version,
    MGMT-IP, API-URL, Config-Hash
  - Peers-Tabelle nur wenn vorhanden, mit "drift"-Marker pro Row
  - Drift-Alert-Banner wenn ein Peer einen anderen config_hash hat
  - Single-Node-Mode Hinweis-Alert ("cluster-join kommt in 3.2")

postinst: leeres /etc/edgeguard/node.conf wird angelegt (chown
edgeguard); API auto-befüllt beim ersten boot.

main.go ruft EnsureLocalConfig + EnsureSelfRegistered mit version.

Verifiziert auf der Box (1.0.70):
  - /etc/edgeguard/node.conf hat NODE_ID, HOSTNAME, MGMT_IP=89.163.205.6,
    ROLE=primary
  - ha_nodes-Row: status=online, version=1.0.70, mgmt_ip=89.163.205.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:27:00 +02:00

105 lines
2.7 KiB
Go

package handlers
import (
"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.
func (h *ClusterHandler) Status(c *gin.Context) {
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)
}