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:
203
internal/cluster/local_config.go
Normal file
203
internal/cluster/local_config.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package cluster
|
||||
|
||||
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster-
|
||||
// Peers replizierte Konfiguration. Hält die Identitäts-Werte die jeden
|
||||
// Node einzigartig machen:
|
||||
//
|
||||
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
|
||||
// gespiegelt für Operator-Sichtbarkeit)
|
||||
// HOSTNAME `hostname -f`
|
||||
// MGMT_IP Management-IP (Interface auf dem die API exposed wird;
|
||||
// NIE VIP — wenn diese Box ein VIP übernimmt, bleibt die
|
||||
// MGMT_IP unverändert auf der eigenen Static-IP)
|
||||
// ROLE primary | secondary
|
||||
// PEER_HOSTS comma-separated FQDNs der anderen Cluster-Peers
|
||||
// (leer im Single-Node-Mode)
|
||||
//
|
||||
// Format ist INI-style ohne sections — eine `KEY=VALUE`-Zeile pro
|
||||
// Eintrag. Kommentare mit `#`. Whitespace um `=` wird getrimmt.
|
||||
//
|
||||
// Postinst legt eine leere/auto-befüllte Datei an. Backup-System
|
||||
// INCLUDIERT diese Datei (sie ist Teil des Node-State); im Cluster-
|
||||
// Sync-Path bleibt sie aber explizit DRAUSSEN.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultLocalConfigPath = "/etc/edgeguard/node.conf"
|
||||
|
||||
type LocalConfig struct {
|
||||
NodeID string
|
||||
Hostname string
|
||||
MgmtIP string // Management-IP des Nodes (IPv4/v6, ohne CIDR)
|
||||
Role string // "primary" | "secondary"
|
||||
PeerHosts []string // andere Cluster-Peers (FQDNs)
|
||||
}
|
||||
|
||||
// LoadLocalConfig liest die Datei. Wenn sie nicht existiert: returns
|
||||
// nil, nil — kein Fehler (single-node default).
|
||||
func LoadLocalConfig(path string) (*LocalConfig, error) {
|
||||
if path == "" {
|
||||
path = DefaultLocalConfigPath
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
c := &LocalConfig{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
val = strings.Trim(val, `"`)
|
||||
switch strings.ToUpper(key) {
|
||||
case "NODE_ID":
|
||||
c.NodeID = val
|
||||
case "HOSTNAME":
|
||||
c.Hostname = val
|
||||
case "MGMT_IP":
|
||||
c.MgmtIP = val
|
||||
case "ROLE":
|
||||
c.Role = strings.ToLower(val)
|
||||
case "PEER_HOSTS":
|
||||
for _, h := range strings.Split(val, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
c.PeerHosts = append(c.PeerHosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, sc.Err()
|
||||
}
|
||||
|
||||
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root.
|
||||
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilegien.
|
||||
func SaveLocalConfig(path string, c *LocalConfig) error {
|
||||
if path == "" {
|
||||
path = DefaultLocalConfigPath
|
||||
}
|
||||
if c == nil {
|
||||
return errors.New("nil config")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Managed by edgeguard — node-local cluster identity.\n")
|
||||
b.WriteString("# NIEMALS zwischen Cluster-Peers replizieren!\n")
|
||||
b.WriteString("# Backup-System sichert diese Datei (Teil des Node-State).\n\n")
|
||||
fmt.Fprintf(&b, "NODE_ID=%s\n", c.NodeID)
|
||||
fmt.Fprintf(&b, "HOSTNAME=%s\n", c.Hostname)
|
||||
fmt.Fprintf(&b, "MGMT_IP=%s\n", c.MgmtIP)
|
||||
if c.Role == "" {
|
||||
c.Role = "primary"
|
||||
}
|
||||
fmt.Fprintf(&b, "ROLE=%s\n", c.Role)
|
||||
fmt.Fprintf(&b, "PEER_HOSTS=%s\n", strings.Join(c.PeerHosts, ","))
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// EnsureLocalConfig liest die Datei; legt sie an wenn nicht vorhanden
|
||||
// (autogeneriert NodeID via EnsureNodeID, Hostname via os.Hostname,
|
||||
// MgmtIP via firstNonLoopbackIPv4). Schreibt nur dann zurück wenn
|
||||
// vorher nichts da war ODER NodeID/Hostname noch leer waren.
|
||||
func EnsureLocalConfig(path string) (*LocalConfig, error) {
|
||||
if path == "" {
|
||||
path = DefaultLocalConfigPath
|
||||
}
|
||||
c, err := LoadLocalConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil {
|
||||
c = &LocalConfig{}
|
||||
}
|
||||
dirty := false
|
||||
if c.NodeID == "" {
|
||||
id, _ := EnsureNodeID("")
|
||||
c.NodeID = id
|
||||
dirty = true
|
||||
}
|
||||
if c.Hostname == "" {
|
||||
h, _ := os.Hostname()
|
||||
c.Hostname = h
|
||||
dirty = true
|
||||
}
|
||||
if c.MgmtIP == "" {
|
||||
c.MgmtIP = firstNonLoopbackIPv4()
|
||||
dirty = true
|
||||
}
|
||||
if c.Role == "" {
|
||||
c.Role = "primary"
|
||||
dirty = true
|
||||
}
|
||||
if !dirty {
|
||||
return c, nil
|
||||
}
|
||||
if err := SaveLocalConfig(path, c); err != nil {
|
||||
// File-not-writable (z.B. dev box als nicht-root): nicht fatal.
|
||||
// Caller bekommt trotzdem den in-memory config.
|
||||
return c, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// firstNonLoopbackIPv4 sucht eine plausible MGMT_IP für den
|
||||
// Default-Case. Operator überschreibt das in /etc/edgeguard/node.conf
|
||||
// wenn die Box mehrere Interfaces hat und wir das falsche gepickt
|
||||
// haben.
|
||||
func firstNonLoopbackIPv4() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipnet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip4 := ipnet.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
return ip4.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -26,7 +26,8 @@ type Store struct {
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, role,
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
|
||||
version, config_hash, status,
|
||||
last_seen, joined_at, created_at, updated_at
|
||||
FROM ha_nodes
|
||||
`
|
||||
@@ -63,45 +64,57 @@ func (s *Store) Get(ctx context.Context, id string) (*models.HANode, error) {
|
||||
// UpsertSelf writes the local node's row using the database-side
|
||||
// ON CONFLICT DO UPDATE so the call is safe to make on every boot.
|
||||
// last_seen is also bumped — handy for the heartbeat-by-restart
|
||||
// pattern even before periodic heartbeats land.
|
||||
// pattern even before periodic heartbeats land. Phase-3-Felder
|
||||
// (mgmt_ip, version, config_hash, status) werden mit COALESCE
|
||||
// erhalten falls der Caller sie nicht setzt.
|
||||
func (s *Store) UpsertSelf(ctx context.Context, n models.HANode) (*models.HANode, error) {
|
||||
now := time.Now().UTC()
|
||||
if n.Status == "" {
|
||||
n.Status = "online"
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO ha_nodes (id, name, fqdn, api_url, public_ip, internal_ip, role, last_seen, joined_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
INSERT INTO ha_nodes (id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||
role, version, config_hash, status, last_seen, joined_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
fqdn = EXCLUDED.fqdn,
|
||||
api_url = EXCLUDED.api_url,
|
||||
public_ip = COALESCE(EXCLUDED.public_ip, ha_nodes.public_ip),
|
||||
public_ip = COALESCE(EXCLUDED.public_ip, ha_nodes.public_ip),
|
||||
internal_ip = COALESCE(EXCLUDED.internal_ip, ha_nodes.internal_ip),
|
||||
role = EXCLUDED.role,
|
||||
last_seen = EXCLUDED.last_seen,
|
||||
updated_at = NOW()
|
||||
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, role,
|
||||
mgmt_ip = COALESCE(EXCLUDED.mgmt_ip, ha_nodes.mgmt_ip),
|
||||
role = EXCLUDED.role,
|
||||
version = COALESCE(EXCLUDED.version, ha_nodes.version),
|
||||
config_hash = COALESCE(EXCLUDED.config_hash, ha_nodes.config_hash),
|
||||
status = EXCLUDED.status,
|
||||
last_seen = EXCLUDED.last_seen,
|
||||
updated_at = NOW()
|
||||
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||
role, version, config_hash, status,
|
||||
last_seen, joined_at, created_at, updated_at`,
|
||||
n.ID, n.Name, n.FQDN, n.APIURL,
|
||||
n.PublicIP, n.InternalIP, n.Role,
|
||||
n.PublicIP, n.InternalIP, n.MgmtIP,
|
||||
n.Role, n.Version, n.ConfigHash, n.Status,
|
||||
now, now,
|
||||
)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||
// from setup.json + os.Hostname, and upserts it. Called on edgeguard-
|
||||
// api boot AFTER the DB pool is reachable.
|
||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||
//
|
||||
// fqdn = setup-store fqdn (preferred) or hostname.
|
||||
// apiURL = "https://<fqdn>" (HAProxy-fronted; v1 doesn't yet know if
|
||||
// the operator runs on a non-default port).
|
||||
func EnsureSelfRegistered(ctx context.Context, store *Store, fqdn string, role string) (*models.HANode, error) {
|
||||
// apiURL = "https://<fqdn>".
|
||||
// version = edgeguard-api-Version (für Drift-Banner).
|
||||
// mgmtIP = aus /etc/edgeguard/node.conf wenn vorhanden, sonst Auto.
|
||||
func EnsureSelfRegistered(ctx context.Context, store *Store, fqdn, role, version string) (*models.HANode, error) {
|
||||
id, err := EnsureNodeID("")
|
||||
if err != nil {
|
||||
// Even when persistence failed (read-only /var/lib in dev),
|
||||
// EnsureNodeID returns the in-memory id alongside the error
|
||||
// — so we can still register, but the id will rotate on
|
||||
// every boot. Surface as warning to the caller; here we
|
||||
// just keep going so the dev box doesn't stay un-registered.
|
||||
// every boot.
|
||||
_ = err
|
||||
}
|
||||
if id == "" {
|
||||
@@ -114,12 +127,26 @@ func EnsureSelfRegistered(ctx context.Context, store *Store, fqdn string, role s
|
||||
if fqdn == "" {
|
||||
fqdn = host
|
||||
}
|
||||
cfg, _ := LoadLocalConfig("")
|
||||
var mgmtIP *string
|
||||
if cfg != nil && cfg.MgmtIP != "" {
|
||||
v := cfg.MgmtIP
|
||||
mgmtIP = &v
|
||||
}
|
||||
var ver *string
|
||||
if version != "" {
|
||||
v := version
|
||||
ver = &v
|
||||
}
|
||||
n := models.HANode{
|
||||
ID: id,
|
||||
Name: host,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn,
|
||||
Role: role,
|
||||
ID: id,
|
||||
Name: host,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn,
|
||||
MgmtIP: mgmtIP,
|
||||
Role: role,
|
||||
Version: ver,
|
||||
Status: "online",
|
||||
}
|
||||
return store.UpsertSelf(ctx, n)
|
||||
}
|
||||
@@ -128,7 +155,8 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
|
||||
var n models.HANode
|
||||
if err := row.Scan(
|
||||
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
|
||||
&n.PublicIP, &n.InternalIP, &n.Role,
|
||||
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
|
||||
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
|
||||
&n.LastSeen, &n.JoinedAt,
|
||||
&n.CreatedAt, &n.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user