feat(cluster): Rolling Update — Secondary-first upgrade orchestration (v1.2.3)
POST /cluster/rolling-update startet den gestaffelten Upgrade-Prozess: 1. Secondary via mTLS /agent/cluster/trigger-update anstoßen 2. /agent/cluster/version pollen bis Secondary Version-Flip zeigt (max 10 min) 3. Primary self-upgrade via systemd-run (identisch zu /system/upgrade) State wird in /var/lib/edgeguard/rolling-update-state.json persistiert: Phasen: updating-secondary → waiting-secondary → updating-primary. "done" wird nicht geschrieben — Prozess stirbt beim Upgrade. UI erkennt Abschluss via /system/health version-flip (analog Single-Node-Upgrade). UI: UpdateBanner erkennt Cluster-Modus (/cluster/status mode="cluster") und tauscht den "Install now"-Button gegen "Rolling Update (Cluster)" aus. Multi-Step-Modal zeigt die drei Phasen; ab updating-primary wechselt der Client auf /system/health polling. Aggregator.PostPeer: neuer einzel-POST-Helper für mTLS-trigger-update. WithVersion(): ClusterHandler bekommt Binary-Version für /agent/cluster/version. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
@@ -60,7 +61,7 @@ import (
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
var version = "1.1.162"
|
||||
var version = "1.2.3"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
@@ -175,6 +176,22 @@ func main() {
|
||||
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
|
||||
}
|
||||
|
||||
// Secondary: push config_hash to primary every 5 min so the primary's
|
||||
// ha_nodes reflects actual state. Without this, the primary retains the
|
||||
// stale hash written at join-time and the drift banner never clears.
|
||||
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
|
||||
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
|
||||
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
|
||||
} else {
|
||||
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
||||
}
|
||||
// Logical Replication liefert Änderungen automatisch — aber Service-
|
||||
// Configs (haproxy.cfg, nftables …) müssen nach jeder Änderung neu
|
||||
// gerendert werden. Diese Goroutine erkennt hash-Änderungen und rendert.
|
||||
go runSecondaryConfigRender(context.Background(), pool)
|
||||
}
|
||||
|
||||
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||
// frisch installierten Single-Node generieren wir die CA und
|
||||
// signieren uns selbst, damit der Agent-Listener auf :8443
|
||||
@@ -324,7 +341,8 @@ func main() {
|
||||
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
|
||||
WithAggregator(clusterAggregator).
|
||||
WithJoinFlow(clusterTLSStore, joinTokens).
|
||||
WithPeerReloader(peerReloader)
|
||||
WithPeerReloader(peerReloader).
|
||||
WithVersion(version)
|
||||
clusterHdl.Register(authed)
|
||||
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
|
||||
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
|
||||
@@ -680,6 +698,84 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
|
||||
}
|
||||
}
|
||||
|
||||
// runSecondaryConfigRender läuft auf Secondary-Nodes und re-rendert alle
|
||||
// Service-Configs wenn die Logical Replication Änderungen vom Primary
|
||||
// geliefert hat. Erkennt das an einem geänderten config_hash.
|
||||
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
|
||||
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
|
||||
const tick = 5 * time.Minute
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
var lastHash string
|
||||
render := func() {
|
||||
rCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
hash, err := cluster.ComputeConfigHash(rCtx, pool)
|
||||
if err != nil || hash == lastHash {
|
||||
return
|
||||
}
|
||||
lastHash = hash
|
||||
slog.Info("cluster: secondary config changed via replication, re-rendering", "hash", hash)
|
||||
// HAProxy
|
||||
if err := haproxy.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary haproxy render failed", "error", err)
|
||||
}
|
||||
// nftables
|
||||
if err := firewallrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary nftables render failed", "error", err)
|
||||
}
|
||||
// Weitere Dienste (Squid, Unbound, Chrony, WireGuard) werden bei
|
||||
// Änderungen an ihren spezifischen Tabellen ebenfalls neu gerendert.
|
||||
// render-config ohne Reload: die Dienste merken Änderungen selbst
|
||||
// (HAProxy/nftables über systemctl reload, der oben bereits läuft).
|
||||
}
|
||||
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
render()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPrimaryPush periodically pushes this secondary node's config_hash to the
|
||||
// primary via mTLS. The primary's ha_nodes view only gets config_hash written
|
||||
// during join-time autoRegister — after that the primary never hears about
|
||||
// hash changes unless we push. Without this, the drift banner shows stale
|
||||
// hashes from join-time forever.
|
||||
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
||||
const tick = 5 * time.Minute
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
|
||||
slog.Warn("cluster: push-to-primary failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
|
||||
}
|
||||
}
|
||||
push() // immediate push on API startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
push()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomEphemeralSecret() []byte {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -41,7 +41,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.1.162"
|
||||
var version = "1.2.3"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
|
||||
Reference in New Issue
Block a user