fix(setup): CompleteAsNode idempotent — konvertiert Standalone zu Cluster-Node

Wenn setup bereits abgeschlossen war (z.B. zuvor als Standalone eingerichtet),
wird der Node jetzt ohne Fehler auf is_cluster_node=true umgestellt statt
mit "setup already completed" zu fehlschlagen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 17:21:17 +02:00
parent 2e84268f61
commit 6a5aff69d9
5 changed files with 18 additions and 8 deletions

View File

@@ -1 +1 @@
1.1.151 1.1.152

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
) )
var version = "1.1.151" var version = "1.1.152"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
) )
var version = "1.1.151" var version = "1.1.152"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.1.151" var version = "1.1.152"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -168,6 +168,10 @@ func (s *Store) Complete(req Request) (*State, error) {
// CompleteAsNode configures this node as a secondary cluster member. // CompleteAsNode configures this node as a secondary cluster member.
// No admin credentials are created; login becomes available after PG // No admin credentials are created; login becomes available after PG
// replication brings users from the primary. // replication brings users from the primary.
//
// Idempotent: if setup was already completed (e.g. the node was
// previously configured as standalone), it is converted to cluster-node
// mode. Calling again after a successful join is a no-op.
func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) { func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
if !looksLikeFQDN(req.FQDN) { if !looksLikeFQDN(req.FQDN) {
return nil, fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN) return nil, fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN)
@@ -179,16 +183,22 @@ func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if prev.Completed {
return nil, errors.New("setup already completed")
}
now := time.Now().UTC() now := time.Now().UTC()
completedAt := &now
if prev.CompletedAt != nil {
completedAt = prev.CompletedAt
}
st := &State{ st := &State{
FQDN: strings.TrimSpace(req.FQDN), FQDN: strings.TrimSpace(req.FQDN),
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)), ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
IsClusterNode: true, IsClusterNode: true,
Completed: true, Completed: true,
CompletedAt: &now, CompletedAt: completedAt,
// Carry over non-auth fields from previous state.
LicenseKey: prev.LicenseKey,
IPv6Enabled: prev.IPv6Enabled,
BackupRetentionKeep: prev.BackupRetentionKeep,
AuditRetentionDays: prev.AuditRetentionDays,
} }
if err := s.Save(st); err != nil { if err := s.Save(st); err != nil {
return nil, err return nil, err