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>
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
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 is read-only:
|
||||
// the UI shows the list of registered nodes but cluster-join + write
|
||||
// operations land in Phase 3.1.
|
||||
// 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
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
@@ -22,6 +25,7 @@ func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
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) {
|
||||
@@ -30,8 +34,71 @@ func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"nodes": nodes,
|
||||
"local_id": h.LocalID,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user