Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6149670375 | ||
|
|
1b13df4032 | ||
|
|
3ca37ee226 | ||
|
|
86aee33308 | ||
|
|
4be9f7f280 | ||
|
|
a34457f13f |
@@ -168,7 +168,8 @@ func main() {
|
||||
if st != nil && st.Completed {
|
||||
// Auto-create /etc/edgeguard/node.conf falls fehlt.
|
||||
_, _ = cluster.EnsureLocalConfig("")
|
||||
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil {
|
||||
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN,
|
||||
localClusterRole(ctx, pool, st), version); err != nil {
|
||||
slog.Warn("self-register in ha_nodes failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale
|
||||
// ha_nodes-Zeile.
|
||||
//
|
||||
// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem
|
||||
// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch
|
||||
// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In
|
||||
// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als
|
||||
// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten
|
||||
// Neustart.
|
||||
//
|
||||
// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role
|
||||
// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
|
||||
// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
|
||||
// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt).
|
||||
//
|
||||
// Verlässlich ist — wie in cluster_repair.go dokumentiert — die
|
||||
// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur
|
||||
// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User
|
||||
// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat
|
||||
// der neue Primary die Publication und meldet sich ab dem nächsten Start
|
||||
// korrekt als "primary" — anders als eine Ableitung aus setup.json, die
|
||||
// den Promote überschreiben würde.
|
||||
func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string {
|
||||
if pool != nil {
|
||||
var hasPub, hasSub bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`,
|
||||
).Scan(&hasPub); err == nil && hasPub {
|
||||
return "primary"
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`,
|
||||
).Scan(&hasSub); err == nil && hasSub {
|
||||
return "peer"
|
||||
}
|
||||
}
|
||||
// Keine Replikation eingerichtet: ein per Join dazugekommener Node ist
|
||||
// trotzdem kein Primary, alles andere (Founder/Single-Node) schon.
|
||||
if st != nil && st.IsClusterNode {
|
||||
return "peer"
|
||||
}
|
||||
return "primary"
|
||||
}
|
||||
|
||||
@@ -25,8 +25,15 @@ type Store struct {
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
|
||||
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
|
||||
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
|
||||
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
|
||||
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
|
||||
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
|
||||
// Scan-Reihenfolge nicht.
|
||||
const baseSelect = `
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
|
||||
version, config_hash, status,
|
||||
last_seen, joined_at, created_at, updated_at
|
||||
FROM ha_nodes
|
||||
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
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,
|
||||
role, pg_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.MgmtIP,
|
||||
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
|
||||
if err := row.Scan(
|
||||
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
|
||||
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
|
||||
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
|
||||
&n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
|
||||
&n.LastSeen, &n.JoinedAt,
|
||||
&n.CreatedAt, &n.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -424,8 +424,9 @@
|
||||
"split-brain": "split-brain"
|
||||
},
|
||||
"roleDbPrimary": "DB-Primary",
|
||||
"rolePeer": "Peer",
|
||||
"roleHint": "DB-/Cluster-Rolle: bestimmt, wohin Schreibzugriffe gehen. Sie wandert NICHT mit der VIP — ein Node kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“."
|
||||
"roleHint": "Datenbank-Rolle aus der Replikation: DB-Primary nimmt Schreibzugriffe entgegen, DB-Standby repliziert von dort. Sie wandert NICHT mit der VIP — ein Knoten kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“.",
|
||||
"roleDbStandby": "DB-Standby",
|
||||
"roleDbUnknown": "—"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
|
||||
@@ -424,8 +424,9 @@
|
||||
"split-brain": "split-brain"
|
||||
},
|
||||
"roleDbPrimary": "DB primary",
|
||||
"rolePeer": "Peer",
|
||||
"roleHint": "Database/cluster role: decides where writes go. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\"."
|
||||
"roleHint": "Database role from replication: the DB primary accepts writes, the DB standby replicates from it. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\".",
|
||||
"roleDbStandby": "DB standby",
|
||||
"roleDbUnknown": "—"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
|
||||
@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
|
||||
interface FwNAT { id: number; enabled: boolean; kind: string }
|
||||
interface FwZone { id: number; name: string; builtin: boolean }
|
||||
interface TLSCert { id: number; common_name: string; not_after?: string }
|
||||
interface ClusterNode { id: string; fqdn: string; role: string }
|
||||
interface ClusterNode { id: string; fqdn: string; role: string; pg_role?: string }
|
||||
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
||||
interface WGStatusRow {
|
||||
interface: string
|
||||
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
||||
|
||||
// ── Cluster card ──────────────────────────────────────────────
|
||||
|
||||
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
|
||||
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
|
||||
// zu zeigen, statt eine zu erfinden.
|
||||
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
|
||||
switch (pgRole) {
|
||||
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
|
||||
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
|
||||
default: return t('dashboard.clusterCard.roleDbUnknown')
|
||||
}
|
||||
}
|
||||
function dbRoleColor(pgRole: string | undefined): string {
|
||||
switch (pgRole) {
|
||||
case 'primary': return 'green'
|
||||
case 'standby': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
interface ClusterStatusCardProps {
|
||||
nodes: ClusterNode[]
|
||||
status: { mode: string; health: string; drift_found: boolean } | null
|
||||
@@ -677,16 +695,19 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
|
||||
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
||||
}}>
|
||||
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
||||
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
|
||||
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
|
||||
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
|
||||
hier las sich neben der VIP-Karte ("BACKUP") wie ein
|
||||
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
|
||||
{/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
|
||||
repliziert), und jeder Node traegt sich in `role` selbst ein —
|
||||
ein per Join dazugekommener Node behaelt dort den Default
|
||||
"primary" und behauptete deshalb in seiner eigenen Ansicht,
|
||||
beide Knoten seien DB-Primary. `pg_role` folgt der
|
||||
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
|
||||
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
|
||||
node-lokal, verlaesslich ist letztlich die PUBLICATION.
|
||||
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
|
||||
durch `edgeguard-ctl promote`. */}
|
||||
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
||||
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
|
||||
{n.role === 'primary'
|
||||
? t('dashboard.clusterCard.roleDbPrimary')
|
||||
: t('dashboard.clusterCard.rolePeer')}
|
||||
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
|
||||
{dbRoleLabel(n.pg_role, t)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user