feat(cluster): (c) Phase-3 MVP — stable node-id + self-register + Cluster-Page

Minimal-Slice für Phase-3-Cluster:
* internal/cluster/node_id.go — stable UUID 'n-<16hex>' in
  /var/lib/edgeguard/node-id, idempotent über reboots.
* internal/cluster/store.go — ha_nodes-Repo (List/Get/UpsertSelf)
  via pgxpool. EnsureSelfRegistered upsertet die lokale Row beim
  Boot mit FQDN aus setup.json.
* internal/handlers/cluster.go — GET /api/v1/cluster/nodes liefert
  alle ha_nodes plus local_id (für UI-Highlighting).
* main.go: nach DB-Pool-Open wird EnsureSelfRegistered (nur wenn
  setup.completed) ausgeführt, ClusterHandler registriert.
* management-ui/src/pages/Cluster/index.tsx — Tabelle mit Node-ID,
  FQDN, Rolle, Beitrittszeit; eigene Node mit "diese Node"-Tag
  markiert. Sidebar-Eintrag + i18n de/en.

Bewusst NICHT in dieser Runde: cluster-init/cluster-join CLIs, KeyDB
Active-Active config-gen, PG streaming replication, mTLS zwischen
Peers, License-Leader-Election. Diese kommen mit dem ersten echten
Multi-Node-Test (Phase 3.1) — sonst Code ohne Smoke-Möglichkeit.

End-to-end-Smoke: setup → restart → ha_nodes hat 1 Row mit
fqdn=eg.example.com, /cluster/nodes liefert sie korrekt mit
local_id-Markierung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 11:52:54 +02:00
parent 6525cb1a41
commit cb5691cf3c
10 changed files with 421 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
@@ -93,7 +94,25 @@ func main() {
"error", err)
} else {
slog.Info("DB pool open, registering CRUD handlers")
nodeID := nodeIDOrHostname()
nodeID, nodeErr := cluster.EnsureNodeID("")
if nodeErr != nil {
slog.Warn("node-id not persisted, using ephemeral",
"id", nodeID, "error", nodeErr)
}
clusterStore := cluster.NewStore(pool)
// Self-register in ha_nodes — only if setup is complete
// (we want the operator-defined FQDN, not the OS hostname,
// to land in api_url). Failures are logged but non-fatal.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
st, _ := setupStore.Load()
if st != nil && st.Completed {
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary"); err != nil {
slog.Warn("self-register in ha_nodes failed", "error", err)
}
}
cancel()
auditRepo := audit.New(pool)
domainsRepo := domains.New(pool)
@@ -105,6 +124,7 @@ func main() {
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed)
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed)
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
}
mountUI(r)