Compare commits

...

3 Commits

Author SHA1 Message Date
Marcel Fest
b514ae2733 fix: regression on tests as we moved to context
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 17:20:53 +02:00
Marcel Fest
26eab74f3e fix(services): coordinate service, election and manager lifecycle
Serialize per-Service state behind UID locks, order events per Service, and
make readiness and watcher ownership generation-aware. Coordinate shared lease
membership so cleanup cannot cancel a recreated Service, drain cluster workers
before restart, and start the shutdown watcher before slow startup calls.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 17:20:52 +02:00
Marcel Fest
44a67bc901 fix: regression on preserveOnLeadershipLoss
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:44:08 +02:00
43 changed files with 7036 additions and 1743 deletions

View File

@@ -347,8 +347,8 @@ var kubeVipManager = &cobra.Command{
defer wg.Wait()
// create main manager context
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
ctx, cancel := context.WithCancelCause(cmd.Context())
defer cancel(nil)
// start prometheus server
if initConfig.PrometheusHTTPServer != "" {
@@ -449,9 +449,8 @@ var kubeVipManager = &cobra.Command{
wg.Go(func() {
if err := vip.MonitorDefaultInterface(ctx, defaultIF); err != nil {
log.Error("interface monitor", "err", err)
return
cancel(err)
}
})
}

View File

@@ -16,14 +16,14 @@ import (
"github.com/kube-vip/kube-vip/pkg/networkinterface"
"github.com/kube-vip/kube-vip/pkg/node"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/kube-vip/kube-vip/pkg/vip"
)
// Cluster - The Cluster object manages the state of the cluster for a particular node
type Cluster struct {
stop chan bool
stopMutex sync.Mutex
stop chan struct{}
stopMu sync.Mutex
service *servicesWorker
Network []vip.Network
arpMgr *arp.Manager
routeMgr *route.Manager
@@ -32,6 +32,13 @@ type Cluster struct {
healthCheckHTTPClient *http.Client
}
type servicesWorker struct {
stop chan struct{}
done chan struct{}
stopping bool
preserveVIPs map[string]struct{}
}
// InitCluster - Will attempt to initialise all of the required settings for the cluster
func InitCluster(c *kubevip.Config, disableVIP bool, intfMgr *networkinterface.Manager, arpMgr *arp.Manager,
routeMgr *route.Manager, nodeLabelMgr node.Labeler) (*Cluster, error) {
@@ -58,7 +65,7 @@ func InitCluster(c *kubevip.Config, disableVIP bool, intfMgr *networkinterface.M
newCluster := &Cluster{
Network: networks,
arpMgr: arpMgr,
stop: make(chan bool),
stop: make(chan struct{}),
routeMgr: routeMgr,
nodeLabelMgr: nodeLabelMgr,
healthCheckHTTPClient: healthCheckHTTPClient,
@@ -95,14 +102,115 @@ func startNetworking(c *kubevip.Config, intfMgr *networkinterface.Manager) ([]vi
// Stop - Will stop the Cluster and release VIP if needed
func (cluster *Cluster) Stop() {
cluster.stopMutex.Lock()
defer cluster.stopMutex.Unlock()
// Close the stop channel, which will shut down the VIP (if needed)
if cluster.stop != nil {
close(cluster.stop)
cluster.stop = make(chan bool) // recreate channel for future use
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
if cluster.service != nil {
workers := cluster.service
if workers.stopping {
return
}
workers.stopping = true
cluster.stop = make(chan struct{})
close(workers.stop)
return
}
stop := cluster.stop
cluster.stop = make(chan struct{})
close(stop)
}
// StopAndWait signals the current Service worker generation and waits until it
// has finished its datapath cleanup.
func (cluster *Cluster) StopAndWait() {
cluster.stopAndWait(nil)
}
// StopAndWaitPreserving stops the current Service worker generation while
// preserving the supplied VIPs for another Service that shares the same lease.
func (cluster *Cluster) StopAndWaitPreserving(addresses ...string) {
preserve := make(map[string]struct{}, len(addresses))
for _, address := range addresses {
preserve[address] = struct{}{}
}
cluster.stopAndWait(preserve)
}
func (cluster *Cluster) stopAndWait(preserveVIPs map[string]struct{}) {
workers, signal := cluster.prepareServiceStop(preserveVIPs)
if workers == nil {
return
}
if signal {
close(workers.stop)
}
<-workers.done
}
func (cluster *Cluster) prepareServiceStop(preserveVIPs map[string]struct{}) (*servicesWorker, bool) {
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
workers := cluster.service
if workers == nil {
return nil, false
}
if workers.stopping {
workers.preserveVIPs = mergeVIPs(workers.preserveVIPs, preserveVIPs)
return workers, false
}
workers.stopping = true
workers.preserveVIPs = preserveVIPs
cluster.stop = make(chan struct{})
return workers, true
}
func (cluster *Cluster) startServicesWorker() (<-chan struct{}, chan struct{}, error) {
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
if cluster.service != nil {
return nil, nil, fmt.Errorf("load balancer workers already running")
}
workers := &servicesWorker{stop: cluster.stop, done: make(chan struct{})}
cluster.service = workers
return workers.stop, workers.done, nil
}
func (cluster *Cluster) preserveServiceVIP(done chan struct{}, address string) bool {
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
if cluster.service == nil || cluster.service.done != done {
return false
}
_, preserve := cluster.service.preserveVIPs[address]
return preserve
}
func mergeVIPs(existing, addresses map[string]struct{}) map[string]struct{} {
if len(addresses) == 0 {
return existing
}
if existing == nil {
existing = make(map[string]struct{}, len(addresses))
}
for address := range addresses {
existing[address] = struct{}{}
}
return existing
}
func (cluster *Cluster) finishServicesWorker(done chan struct{}) {
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
if cluster.service == nil || cluster.service.done != done {
return
}
cluster.service = nil
close(done)
}
func (cluster *Cluster) StopChannel() <-chan struct{} {
cluster.stopMu.Lock()
defer cluster.stopMu.Unlock()
return cluster.stop
}
func newHealthCheckHTTPClient(c *kubevip.Config) (*http.Client, error) {
@@ -140,38 +248,41 @@ func newHealthCheckHTTPClient(c *kubevip.Config) (*http.Client, error) {
}, nil
}
// cleanupVIPs handles VIP removal based on the PreserveVIPOnLeadershipLoss configuration.
// When preservation is enabled, IPv6 VIPs are always removed immediately to prevent DAD
// failures on the new leader, while IPv4 VIPs are intentionally left in place.
// When preservation is disabled (legacy behavior), all VIPs are removed.
// cleanupVIPs releases the control plane VIPs after leadership was lost.
// Nothing waits for the control plane layer2Update goroutine to observe the
// cancelled context, so this caller usually still holds its own ARP claim and
// has to delete the address itself.
func (cluster *Cluster) cleanupVIPs(c *kubevip.Config) {
for i := range cluster.Network {
if c.EnableARP && cluster.arpMgr.Count(cluster.Network[i].ARPName()) > 1 {
continue
}
if c.PreserveVIPOnLeadershipLoss {
if utils.IsIPv6(cluster.Network[i].IP()) {
log.Info("[VIP] Removing IPv6 VIP immediately (required to prevent DAD failures on new leader)", "ip", cluster.Network[i].IP())
deleted, err := cluster.Network[i].DeleteIP()
if err != nil {
log.Warn(err.Error())
}
if deleted {
log.Info("deleted address", "IP", cluster.Network[i].IP(), "interface", cluster.Network[i].Interface())
}
} else {
log.Info("[VIP] Preserving IPv4 VIP address on interface, only stopped ARP broadcasting", "ip", cluster.Network[i].IP())
}
} else {
log.Info("[VIP] Deleting VIP", "ip", cluster.Network[i].IP())
deleted, err := cluster.Network[i].DeleteIP()
if err != nil {
log.Warn(err.Error())
}
if deleted {
log.Info("deleted address", "IP", cluster.Network[i].IP(), "interface", cluster.Network[i].Interface())
}
}
cluster.cleanupVIP(c, cluster.Network[i], 1)
}
}
// cleanupServiceVIPs releases the service VIPs once the services worker has
// drained. layer2Update already removed this instance's own claim by then, so
// any remaining claim belongs to another service sharing the VIP.
func (cluster *Cluster) cleanupServiceVIPs(c *kubevip.Config, done chan struct{}) {
for i := range cluster.Network {
if cluster.preserveServiceVIP(done, cluster.Network[i].IP()) {
continue
}
cluster.cleanupVIP(c, cluster.Network[i], 0)
}
}
// cleanupVIP deletes the VIP unless somebody else still advertises it.
// ownClaims is the number of ARP claims the caller may still hold itself.
func (cluster *Cluster) cleanupVIP(c *kubevip.Config, network vip.Network, ownClaims int) {
if c.EnableARP && cluster.arpMgr.Count(network.ARPName()) > ownClaims {
return
}
log.Info("[VIP] Deleting VIP", "ip", network.IP())
deleted, err := network.DeleteIP()
if err != nil {
log.Warn(err.Error())
}
if deleted {
log.Info("deleted address", "IP", network.IP(), "interface", network.Interface())
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/kube-vip/kube-vip/pkg/vip"
log "log/slog"
)
@@ -25,36 +26,22 @@ func (cluster *Cluster) StartCluster(ctx context.Context, c *kubevip.Config,
log.Info("cluster membership", "namespace", leaseID.Namespace(), "lock", leaseID.Name(), "id", c.NodeName)
objectName := lease.ObjectName(leaseID, "cp")
objLease := leaseMgr.Add(ctx, leaseID)
isNew := objLease.Add(objectName)
objLease, _ := leaseMgr.Acquire(context.Background(), leaseID, objectName)
defer leaseMgr.Delete(leaseID, objectName, objLease)
wg := sync.WaitGroup{}
defer wg.Wait()
// Start a goroutine that will delete the lease when the service context is cancelled.
// This is important for proper cleanup when a service is deleted - it ensures that
// the lease context (svcLease.Ctx) gets cancelled, which causes RunOrDie to return.
// Without this, RunOrDie would continue running until leadership is naturally lost.
wg.Go(func() {
<-objLease.Ctx.Done()
leaseMgr.Delete(leaseID, objectName, objLease)
})
if !isNew {
log.Debug("this election was already done, waiting for it to finish", "lease", leaseName)
<-objLease.Ctx.Done()
return nil
}
electionCtx, cancelElection := objLease.NewElectionContext(ctx)
defer cancelElection()
stop := cluster.StopChannel()
wg.Go(func() {
select {
case <-cluster.stop:
case <-ctx.Done():
case <-stop:
cancelElection()
case <-electionCtx.Done():
}
log.Info("Received termination, signaling cluster shutdown")
// Cancel the leader context, which will in turn cancel the leadership
objLease.Cancel()
})
// (attempt to) Remove the virtual IP, in case it already exists
@@ -69,54 +56,59 @@ func (cluster *Cluster) StartCluster(ctx context.Context, c *kubevip.Config,
}
}
objLease.Lock()
for {
if !objLease.BeginElection() {
log.Debug("this election was already done, shared lease", "lease", leaseName)
leaderGeneration, elected := objLease.WaitForLeaderGeneration(electionCtx)
if !elected {
if electionCtx.Err() != nil {
return nil
}
// The runner that owned this shared lease's election ended it
// without ever being elected; take over the campaign ourselves
// instead of leaving the lease without an active runner.
continue
}
defer func() {
objLease.Unlock()
}()
leaderCtx, cancelLeader := context.WithCancel(electionCtx)
leaderWG := sync.WaitGroup{}
leaderWG.Go(func() {
cluster.OnStartedLeading(leaderCtx, c, em, bgpServer, killFunc, true)
})
// this object is sharing lease with another object
if objLease.Elected.Load() {
log.Debug("this election was already done, shared lease", "lease", leaseName)
// wait for leader election to start or context to be done
select {
case <-objLease.Started:
case <-objLease.Ctx.Done():
// Lease was cancelled (e.g., leader election ended), return immediately
// This allows the restart loop to create a fresh lease
log.Debug("lease context cancelled before leader election started", "lease", leaseName)
return fmt.Errorf("lease %q context cancelled before leader election started", leaseName)
log.Debug("cluster waiting for shared election to finish", "lease", leaseName)
objLease.WaitForElectionEndAfter(electionCtx, leaderGeneration)
cancelLeader()
leaderWG.Wait()
cluster.OnStoppedLeading(c, bgpServer)
return nil
}
cluster.OnStartedLeading(c, objLease, em, bgpServer, killFunc, true)
log.Debug("cluster waiting for leader context done", "lease", leaseName)
// wait for leaderelection to be finished
<-objLease.Ctx.Done()
cluster.OnStoppedLeading(c, objLease, bgpServer)
return nil
break
}
defer objLease.ElectionStopped()
run := &election.RunConfig{
Config: c,
LeaseID: leaseID,
LeaseAnnotations: c.LeaseAnnotations,
VIPs: controlPlaneElectionVIPs(c),
Mgr: em,
OnStartedLeading: func(context.Context) { //nolint TODO: potential clean code
cluster.OnStartedLeading(c, objLease, em, bgpServer, killFunc, false)
OnStartedLeading: func(ctx context.Context) {
objLease.ElectionStarted()
cluster.OnStartedLeading(ctx, c, em, bgpServer, killFunc, false)
},
OnStoppedLeading: func() {
objLease.Elected.Store(false)
cluster.OnStoppedLeading(c, objLease, bgpServer)
objLease.ElectionStopped()
cluster.OnStoppedLeading(c, bgpServer)
},
OnNewLeader: func(identity string) {
cluster.OnNewLeader(identity, c)
},
}
if err := election.RunOrDie(objLease.Ctx, run, c); err != nil {
if err := election.RunOrDie(electionCtx, run, c); err != nil {
cluster.Stop()
return fmt.Errorf("leaderelection failed: %w", err)
}
@@ -124,47 +116,31 @@ func (cluster *Cluster) StartCluster(ctx context.Context, c *kubevip.Config,
return nil
}
func (cluster *Cluster) OnStartedLeading(c *kubevip.Config, objLease *lease.Lease,
em *election.Manager, bgpServer *bgp.Server, killFunc func(), isShared bool) {
objLease.Elected.Store(true)
objLease.Unlock()
// When we become leader, ensure we can take over VIPs even if they're preserved on other nodes
if !isShared {
close(objLease.Started)
func controlPlaneElectionVIPs(config *kubevip.Config) []string {
configured := config.VIP
if config.Address != "" {
configured = config.Address
}
return vip.Split(configured)
}
func (cluster *Cluster) OnStartedLeading(ctx context.Context, c *kubevip.Config,
em *election.Manager, bgpServer *bgp.Server, killFunc func(), _ bool) {
labels := generateLabelsFromConfig(c.Address, kubevip.HasIP)
if err := cluster.nodeLabelMgr.AddLabel(labels); err != nil {
log.Error("error adding label to node", "err", err)
}
cluster.labelAdded = true
if c.PreserveVIPOnLeadershipLoss {
log.Info("Becoming leader with VIP preservation enabled - ensuring VIP takeover")
// Force add the VIPs (this will work even if they exist due to the precheck logic)
for i := range cluster.Network {
added, err := cluster.Network[i].AddIP(true, false)
if err != nil {
log.Error("failed to ensure VIP on leader takeover", "vip", cluster.Network[i].IP(), "err", err)
} else if added {
log.Info("took over VIP as new leader", "IP", cluster.Network[i].IP(), "interface", cluster.Network[i].Interface())
} else {
log.Info("VIP already configured on interface", "IP", cluster.Network[i].IP(), "interface", cluster.Network[i].Interface())
}
}
}
// As we're leading lets start the vip service
err := cluster.StartVipService(objLease.Ctx, c, em, bgpServer, killFunc)
err := cluster.StartVipService(ctx, c, em, bgpServer, killFunc)
if err != nil {
log.Error("starting VIP service on leader", "err", err)
killFunc()
}
}
func (cluster *Cluster) OnStoppedLeading(c *kubevip.Config, objLease *lease.Lease,
bgpServer *bgp.Server) {
func (cluster *Cluster) OnStoppedLeading(c *kubevip.Config, bgpServer *bgp.Server) {
// we can do cleanup here
log.Info("This node is becoming a follower within the cluster")
@@ -176,9 +152,6 @@ func (cluster *Cluster) OnStoppedLeading(c *kubevip.Config, objLease *lease.Leas
cluster.labelAdded = false
}
// Stop the cluster context if it is running
objLease.Cancel()
cluster.cleanupVIPs(c)
log.Error("lost leadership, restarting kube-vip")
@@ -187,25 +160,6 @@ func (cluster *Cluster) OnStoppedLeading(c *kubevip.Config, objLease *lease.Leas
func (cluster *Cluster) OnNewLeader(identity string, c *kubevip.Config) {
// we're notified when new leader elected
log.Info("New leader", "leader", identity)
// If we're not the new leader and we have VIPs preserved from previous leadership,
// we need to clean them up to avoid conflicts.
if identity != c.NodeName && c.PreserveVIPOnLeadershipLoss {
log.Info("Cleaning up preserved VIPs as another node became leader", "new_leader", identity)
for i := range cluster.Network {
deleted, err := cluster.Network[i].DeleteIP()
if err != nil {
log.Warn("failed to cleanup preserved VIP", "vip", cluster.Network[i].IP(), "err", err)
}
if deleted {
log.Info("cleaned up preserved VIP to avoid conflict", "IP", cluster.Network[i].IP(),
"interface", cluster.Network[i].Interface(), "new_leader", identity)
} else {
log.Debug("VIP was not present on this node", "IP", cluster.Network[i].IP(),
"interface", cluster.Network[i].Interface())
}
}
}
}
func generateLabelsFromConfig(addr, labelKey string) map[string]string {

View File

@@ -0,0 +1,196 @@
package cluster
import (
"context"
"slices"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/arp"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/vip"
"github.com/vishvananda/netlink"
v1 "k8s.io/api/core/v1"
)
func TestControlPlaneElectionVIPsPreservesConfigOrder(t *testing.T) {
config := &kubevip.Config{Address: "2001:db8::10,192.0.2.10"}
want := []string{"2001:db8::10", "192.0.2.10"}
if got := controlPlaneElectionVIPs(config); !slices.Equal(got, want) {
t.Fatalf("controlPlaneElectionVIPs() = %v, want %v", got, want)
}
}
type recordingLabeler struct {
added chan struct{}
removed chan struct{}
}
func (l *recordingLabeler) AddLabel(map[string]string) error {
l.added <- struct{}{}
return nil
}
func (l *recordingLabeler) RemoveLabel(map[string]string) error {
l.removed <- struct{}{}
return nil
}
// stubNetwork is a minimal vip.Network implementation for exercising
// cleanupVIP without a real interface.
type stubNetwork struct {
ip string
deleteIPCalls int
}
func (s *stubNetwork) AddIP(bool, bool, ...int) (bool, error) { return false, nil }
func (s *stubNetwork) AddRoute(bool) (bool, error) { return false, nil }
func (s *stubNetwork) ReplaceRoute() error { return nil }
func (s *stubNetwork) DeleteIP() (bool, error) { s.deleteIPCalls++; return true, nil }
func (s *stubNetwork) DeleteRoute() error { return nil }
func (s *stubNetwork) UpdateRoutes() (bool, error) { return false, nil }
func (s *stubNetwork) IsSet() (*netlink.Addr, error) { return nil, nil }
func (s *stubNetwork) IP() string { return s.ip }
func (s *stubNetwork) CIDR() string { return s.ip + "/32" }
func (s *stubNetwork) IPisLinkLocal() bool { return false }
func (s *stubNetwork) PrepareRoute() *netlink.Route { return nil }
func (s *stubNetwork) RouteHash() string { return "" }
func (s *stubNetwork) SetIP(string) error { return nil }
func (s *stubNetwork) SetServicePorts(*v1.Service) {}
func (s *stubNetwork) Interface() string { return "eth0" }
func (s *stubNetwork) IsDADFAIL() bool { return false }
func (s *stubNetwork) IsDNS() bool { return false }
func (s *stubNetwork) IsDDNS() bool { return false }
func (s *stubNetwork) DDNSHostName() string { return "" }
func (s *stubNetwork) DNSName() string { return "" }
func (s *stubNetwork) SetMask(string) error { return nil }
func (s *stubNetwork) SetHasEndpoints(bool) {}
func (s *stubNetwork) HasEndpoints() bool { return false }
func (s *stubNetwork) ARPName() string { return "shared-vip" }
func (s *stubNetwork) GetPossibleSubnets() string { return "" }
func (s *stubNetwork) DHCPFamily() string { return "" }
func (s *stubNetwork) IPVSMark() uint32 { return 0 }
// TestCleanupVIPRetainsSharedVIPWithOneSiblingLeft reproduces the off-by-one:
// layer2Update already removes its own ARP claim before cleanupVIP runs, so a
// single remaining sibling must still block deletion.
func TestCleanupVIPRetainsSharedVIPWithOneSiblingLeft(t *testing.T) {
arpMgr := arp.NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
netA := &stubNetwork{ip: "192.0.2.10"}
netB := &stubNetwork{ip: "192.0.2.10"}
instA := arp.NewInstance(netA, nil)
instB := arp.NewInstance(netB, nil)
arpMgr.Insert(instA)
arpMgr.Insert(instB)
// Cluster A's layer2Update goroutine ends first and drops its own claim,
// leaving only sibling B registered.
arpMgr.Remove(instA)
c := &Cluster{arpMgr: arpMgr}
c.cleanupVIP(&kubevip.Config{EnableARP: true}, netA, 0)
if netA.deleteIPCalls != 0 {
t.Fatalf("cleanupVIP deleted the shared VIP while a sibling was still registered")
}
}
// TestCleanupVIPsDeletesControlPlaneVIPHoldingItsOwnARPClaim covers the
// leadership loss path: OnStoppedLeading runs concurrently with the control
// plane layer2Update goroutine, so the VIP's only ARP claim is still the
// caller's own and the address must still be removed before the process exits.
func TestCleanupVIPsDeletesControlPlaneVIPHoldingItsOwnARPClaim(t *testing.T) {
arpMgr := arp.NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
network := &stubNetwork{ip: "2001:db8::10"}
arpMgr.Insert(arp.NewInstance(network, nil))
c := &Cluster{arpMgr: arpMgr, Network: []vip.Network{network}}
c.cleanupVIPs(&kubevip.Config{EnableARP: true})
if network.deleteIPCalls != 1 {
t.Fatalf("cleanupVIPs made %d DeleteIP calls, want 1", network.deleteIPCalls)
}
}
func TestControlPlaneFollowsSharedServiceElection(t *testing.T) {
config := &kubevip.Config{KubernetesLeaderElection: kubevip.KubernetesLeaderElection{LeaseName: "default/shared"}}
leaseID := lease.NewID(config.LeaderElectionType, "default", "shared")
leaseMgr := lease.NewManager()
sharedLease, _ := leaseMgr.Acquire(context.Background(), leaseID, "service")
if !sharedLease.BeginElection() {
t.Fatal("Service election did not start")
}
sharedLease.ElectionStarted()
labels := &recordingLabeler{added: make(chan struct{}, 1), removed: make(chan struct{}, 1)}
cluster := &Cluster{stop: make(chan struct{}), nodeLabelMgr: labels}
done := make(chan error, 1)
go func() {
done <- cluster.StartCluster(context.Background(), config, nil, nil, leaseMgr, func() {})
}()
select {
case <-labels.added:
case <-time.After(time.Second):
t.Fatal("control plane did not activate under the shared Service election")
}
sharedLease.ElectionStopped()
select {
case err := <-done:
if err != nil {
t.Fatalf("shared control-plane follower returned an error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("control plane did not stop after shared Service leadership ended")
}
select {
case <-labels.removed:
default:
t.Fatal("control-plane label was not removed after shared leadership ended")
}
if sharedLease.Ctx.Err() != nil || leaseMgr.Get(leaseID) != sharedLease {
t.Fatal("control-plane cleanup cancelled the surviving Service lease")
}
leaseMgr.Delete(leaseID, "service", sharedLease)
}
func TestStopAndWaitPreservingUpgradesInProgressStop(t *testing.T) {
done := make(chan struct{})
service := &Cluster{
stop: make(chan struct{}),
service: &servicesWorker{
stop: make(chan struct{}),
done: done,
stopping: true,
},
}
returned := make(chan struct{})
go func() {
service.StopAndWaitPreserving("192.0.2.10")
close(returned)
}()
deadline := time.Now().Add(time.Second)
for {
service.stopMu.Lock()
_, preserving := service.service.preserveVIPs["192.0.2.10"]
service.stopMu.Unlock()
if preserving {
break
}
if time.Now().After(deadline) {
t.Fatal("preserving stop did not update the in-progress worker shutdown")
}
time.Sleep(time.Millisecond)
}
service.finishServicesWorker(done)
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("preserving stop did not return after worker cleanup completed")
}
}

View File

@@ -7,7 +7,7 @@ import (
)
func TestStopConcurrentDoesNotRaceOrPanic(t *testing.T) {
c := &Cluster{stop: make(chan bool)}
c := &Cluster{stop: make(chan struct{})}
start := make(chan struct{})
var wg sync.WaitGroup
var panics atomic.Int64

View File

@@ -44,6 +44,9 @@ func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config,
loadbalancers := []*loadbalancer.IPVSLoadBalancer{}
for i := range cluster.Network {
if err := ctx.Err(); err != nil {
return err
}
network := cluster.Network[i]
if network.IsDDNS() {
@@ -398,11 +401,54 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
// want to step down
//nolint
lbCtx, lbCancel := context.WithCancel(ctx)
var lbWg sync.WaitGroup
stop, done, err := cluster.startServicesWorker()
if err != nil {
lbCancel()
return err
}
type startedNetwork struct {
network vip.Network
routeAdded bool
ipAdded bool
bgpAdded bool
}
startedNetworks := make([]startedNetwork, 0, len(cluster.Network))
servicesWorkerStarted := false
defer func() {
if !servicesWorkerStarted {
lbCancel()
lbWg.Wait()
cleanupCtx := context.WithoutCancel(ctx)
for index := len(startedNetworks) - 1; index >= 0; index-- {
started := startedNetworks[index]
if started.bgpAdded && bgp != nil {
if err := bgp.DelHost(cleanupCtx, started.network.CIDR(), name); err != nil {
log.Warn("failed to withdraw BGP host after startup failure", "address", started.network.CIDR(), "err", err)
}
}
if started.routeAdded && cluster.routeMgr != nil {
if err := cluster.routeMgr.Delete(name, started.network); err != nil {
log.Warn("failed to delete route after startup failure", "address", started.network.CIDR(), "err", err)
}
}
if started.ipAdded {
if _, err := started.network.DeleteIP(); err != nil {
log.Warn("failed to delete VIP after startup failure", "address", started.network.IP(), "err", err)
}
}
}
cluster.finishServicesWorker(done)
}
}()
for i := range cluster.Network {
if err := ctx.Err(); err != nil {
return err
}
network := cluster.Network[i]
startedNetworks = append(startedNetworks, startedNetwork{network: network})
started := &startedNetworks[len(startedNetworks)-1]
if network.IsDDNS() {
ddnsReady := make(chan struct{})
@@ -425,9 +471,10 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
lbCancel()
return utils.WrapPanicError(err, "failed to set mask for subnet %q", c.VIPSubnet)
}
_, err := network.DeleteIP()
existing, err := network.IsSet()
if err != nil {
log.Warn("attempted to clean existing VIP", "err", err)
lbCancel()
return fmt.Errorf("check existing VIP %q: %w", network.IP(), err)
}
log.Debug("config flags", "enable_routing_table", c.EnableRoutingTable, "enable_leader_election", c.EnableLeaderElection, "enable_services_election", c.EnableServicesElection)
@@ -436,6 +483,7 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
if err != nil {
log.Warn(err.Error())
} else {
started.routeAdded = true
log.Info("successful add Route")
}
}
@@ -444,8 +492,10 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
// Normal VIP addition, use skipDAD=false for normal DAD process
// Note: When WireGuard is enabled, the VIP is added to the tunnel interface
// instead of lo, so we skip adding it here.
if _, err = network.AddIP(false, false); err != nil {
log.Warn(err.Error())
added, addErr := network.AddIP(false, false)
started.ipAdded = existing == nil && added
if addErr != nil {
log.Warn(addErr.Error())
} else {
log.Info("successful add IP", "address", network.IP())
}
@@ -463,11 +513,14 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
err = bgp.AddHost(lbCtx, network.CIDR(), name)
if err != nil {
log.Error(err.Error())
} else {
started.bgpAdded = true
}
}
}
wg.Go(func() {
defer cluster.finishServicesWorker(done)
for i := range cluster.Network {
network := cluster.Network[i]
@@ -482,7 +535,7 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
}
select {
case <-cluster.stop:
case <-stop:
case <-ctx.Done():
}
@@ -503,8 +556,9 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
return
}
cluster.cleanupVIPs(c)
cluster.cleanupServiceVIPs(c, done)
})
servicesWorkerStarted = true
return nil
}

View File

@@ -3,6 +3,7 @@ package cluster_test
import (
"context"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -35,6 +36,110 @@ func TestBGPHealthCheckLoop_AnnouncesOnHealthy(t *testing.T) {
"route should be announced")
}
func TestServicesWorkerStopAndWaitDrainsBeforeRestart(t *testing.T) {
config := &kubevip.Config{}
serviceCluster, err := cluster.InitCluster(config, true, nil, nil, nil, nil)
if err != nil {
t.Fatalf("InitCluster() error = %v", err)
}
var workers sync.WaitGroup
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("first StartLoadBalancerService() error = %v", err)
}
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err == nil {
t.Fatal("second StartLoadBalancerService() started while the first workers were active")
}
serviceCluster.StopAndWait()
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("StartLoadBalancerService() after StopAndWait error = %v", err)
}
serviceCluster.StopAndWait()
workers.Wait()
}
func TestServicesWorkerStopAndWaitPreservingDrainsBeforeRestart(t *testing.T) {
config := &kubevip.Config{}
serviceCluster, err := cluster.InitCluster(config, true, nil, nil, nil, nil)
if err != nil {
t.Fatalf("InitCluster() error = %v", err)
}
var workers sync.WaitGroup
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("StartLoadBalancerService() error = %v", err)
}
serviceCluster.StopAndWait()
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("StartLoadBalancerService() after preserving stop error = %v", err)
}
serviceCluster.StopAndWait()
workers.Wait()
}
func TestStartLoadBalancerServiceRollsBackEarlierNetwork(t *testing.T) {
first := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32"}
second := &mockNetwork{ip: "192.0.2.11", cidr: "192.0.2.11/32", setMaskErr: errors.New("set mask")}
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
if err != nil {
t.Fatalf("InitCluster() error = %v", err)
}
serviceCluster.Network = []vip.Network{first, second}
if err := serviceCluster.StartLoadBalancerService(context.Background(), &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{}); err == nil {
t.Fatal("StartLoadBalancerService() error = nil, want second-network failure")
}
first.mu.Lock()
addCalls, deleteCalls, present := first.addIPCalls, first.deleteIPCalls, first.present
first.mu.Unlock()
if addCalls != 1 || deleteCalls != 1 || present {
t.Fatalf("first network rollback = add %d, delete %d, present %t; want 1, 1, false", addCalls, deleteCalls, present)
}
serviceCluster.StopAndWait()
}
func TestStartLoadBalancerServiceRollbackPreservesExistingVIP(t *testing.T) {
first := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32", present: true}
second := &mockNetwork{ip: "192.0.2.11", cidr: "192.0.2.11/32", setMaskErr: errors.New("set mask")}
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
if err != nil {
t.Fatalf("InitCluster() error = %v", err)
}
serviceCluster.Network = []vip.Network{first, second}
if err := serviceCluster.StartLoadBalancerService(context.Background(), &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{}); err == nil {
t.Fatal("StartLoadBalancerService() error = nil, want second-network failure")
}
first.mu.Lock()
addCalls, deleteCalls, present := first.addIPCalls, first.deleteIPCalls, first.present
first.mu.Unlock()
if addCalls != 1 || deleteCalls != 0 || !present {
t.Fatalf("existing VIP rollback = add %d, delete %d, present %t; want 1, 0, true", addCalls, deleteCalls, present)
}
serviceCluster.StopAndWait()
}
func TestStartLoadBalancerServiceCancelledContextDoesNotConfigureVIP(t *testing.T) {
network := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32"}
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
if err != nil {
t.Fatalf("InitCluster() error = %v", err)
}
serviceCluster.Network = []vip.Network{network}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = serviceCluster.StartLoadBalancerService(ctx, &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("StartLoadBalancerService() error = %v, want context.Canceled", err)
}
network.mu.Lock()
addCalls := network.addIPCalls
network.mu.Unlock()
if addCalls != 0 {
t.Fatalf("AddIP calls = %d, want 0 after context cancellation", addCalls)
}
}
func TestBGPHealthCheckLoop_NoAnnouncementUntilHealthy(t *testing.T) {
t.Parallel()
healthcheck := newTestHealthServer(t, http.StatusInternalServerError)
@@ -323,32 +428,45 @@ type mockNetwork struct {
ip string
cidr string
mu sync.Mutex
present bool
mu sync.Mutex
present bool
setMaskErr error
addIPCalls int
deleteIPCalls int
}
func (m *mockNetwork) AddIP(bool, bool, ...int) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.addIPCalls++
m.present = true
return true, nil
}
func (m *mockNetwork) DeleteIP() (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.deleteIPCalls++
deleted := m.present
m.present = false
return false, nil
return deleted, nil
}
func (m *mockNetwork) isPresent() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.present
}
func (m *mockNetwork) AddRoute(bool) (bool, error) { return false, nil }
func (m *mockNetwork) ReplaceRoute() error { return nil }
func (m *mockNetwork) DeleteRoute() error { return nil }
func (m *mockNetwork) UpdateRoutes() (bool, error) { return false, nil }
func (m *mockNetwork) IsSet() (*netlink.Addr, error) { return nil, nil }
func (m *mockNetwork) AddRoute(bool) (bool, error) { return false, nil }
func (m *mockNetwork) ReplaceRoute() error { return nil }
func (m *mockNetwork) DeleteRoute() error { return nil }
func (m *mockNetwork) UpdateRoutes() (bool, error) { return false, nil }
func (m *mockNetwork) IsSet() (*netlink.Addr, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.present {
return &netlink.Addr{}, nil
}
return nil, nil
}
func (m *mockNetwork) IP() string { return m.ip }
func (m *mockNetwork) CIDR() string { return m.cidr }
func (m *mockNetwork) IPisLinkLocal() bool { return false }
@@ -362,7 +480,7 @@ func (m *mockNetwork) IsDNS() bool { return false }
func (m *mockNetwork) IsDDNS() bool { return false }
func (m *mockNetwork) DDNSHostName() string { return "" }
func (m *mockNetwork) DNSName() string { return "" }
func (m *mockNetwork) SetMask(string) error { return nil }
func (m *mockNetwork) SetMask(string) error { return m.setMaskErr }
func (m *mockNetwork) SetHasEndpoints(bool) {}
func (m *mockNetwork) HasEndpoints() bool { return false }
func (m *mockNetwork) ARPName() string { return "" }

152
pkg/endpoints/cleanup.go Normal file
View File

@@ -0,0 +1,152 @@
package endpoints
import (
"context"
"fmt"
"sync"
log "log/slog"
v1 "k8s.io/api/core/v1"
"github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/egress"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/nftables"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/kube-vip/kube-vip/pkg/wireguard"
)
// CleanupService stops one Service's datapath before its instance is detached.
// The service processor owns labels and instance bookkeeping; this package owns
// endpoint-dependent networking and waits for worker shutdown to complete.
func CleanupService(ctx context.Context, config *kubevip.Config, bgpServer *bgp.Server, routeMgr *route.Manager,
tunnelMgr *wireguard.TunnelManager, serviceInstance *instance.Instance, remaining []*instance.Instance) error {
if serviceInstance == nil || serviceInstance.ServiceSnapshot == nil {
return nil
}
service := serviceInstance.ServiceSnapshot
for _, serviceCluster := range serviceInstance.Clusters {
for _, network := range serviceCluster.Network {
network.SetHasEndpoints(false)
}
}
if config.EnableBGP {
ClearBGPHostsByInstance(ctx, serviceInstance, bgpServer)
}
if config.EnableRoutingTable {
for _, err := range ClearRoutesByInstance(service, serviceInstance, &remaining, routeMgr) {
log.Error("unable to clear routes", "err", err)
}
}
internalNftablesEgress := service.Annotations[kubevip.EgressInternal] != "" || config.EgressWithNftables
if service.Annotations[kubevip.Egress] == "true" && internalNftablesEgress {
if err := nftables.DeleteSNATFromAllTables(string(serviceInstance.UID())); err != nil {
log.Error("[service] nftables egress teardown", "service", service.Name, "err", err)
}
}
sharedVIPs := sharedServiceVIPs(config, serviceInstance, remaining)
for _, serviceCluster := range serviceInstance.Clusters {
preserve := make([]string, 0, len(serviceCluster.Network))
for _, network := range serviceCluster.Network {
if _, shared := sharedVIPs[network.IP()]; shared {
preserve = append(preserve, network.IP())
}
}
if len(preserve) != 0 {
serviceCluster.StopAndWaitPreserving(preserve...)
} else {
serviceCluster.StopAndWait()
}
}
if err := serviceInstance.CleanupLinkAttachments(remaining...); err != nil {
return fmt.Errorf("clean Service link attachments: %w", err)
}
if service.Annotations[kubevip.Egress] == "true" && !internalNftablesEgress && service.Annotations[kubevip.ActiveEndpoint] != "" {
if err := egress.Teardown(service.Annotations[kubevip.ActiveEndpoint], service.Spec.LoadBalancerIP, service.Namespace,
string(serviceInstance.UID()), service.Annotations, config.EgressWithNftables); err != nil {
log.Error("[service] egress teardown", "err", err)
}
}
if config.EnableWireguard {
cleanupWireguardService(tunnelMgr, service)
}
return nil
}
// StartService starts a Service's cluster datapath after endpoint handling has
// made the Service eligible for activation.
func StartService(ctx context.Context, service *v1.Service, serviceInstance *instance.Instance, bgpServer *bgp.Server,
wg *sync.WaitGroup) error {
if serviceInstance == nil {
return fmt.Errorf("missing service instance for %s/%s", service.Namespace, service.Name)
}
for index := range serviceInstance.VIPConfigs {
if err := serviceInstance.Clusters[index].StartLoadBalancerService(ctx, serviceInstance.VIPConfigs[index], bgpServer,
lease.ServiceNamespacedName(service), wg); err != nil {
return fmt.Errorf("start load balancer: %w", err)
}
}
return nil
}
func sharedServiceVIPs(config *kubevip.Config, serviceInstance *instance.Instance, remaining []*instance.Instance) map[string]struct{} {
shared := make(map[string]struct{})
if serviceInstance.ServiceSnapshot == nil ||
serviceInstance.ServiceSnapshot.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
return shared
}
serviceNamespace, serviceLeaseName := lease.ServiceName(serviceInstance.ServiceSnapshot)
serviceLease := lease.NewID(config.LeaderElectionType, serviceNamespace, serviceLeaseName).NamespacedName()
addresses := serviceInstance.Addresses()
for _, candidate := range remaining {
candidateInfo, ok := candidate.CleanupInfo()
if !ok || candidateInfo.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
continue
}
candidateNamespace, candidateLeaseName := lease.ServiceNameFor(candidateInfo.Namespace, candidateInfo.Name, candidateInfo.Lease)
candidateLease := lease.NewID(config.LeaderElectionType, candidateNamespace, candidateLeaseName).NamespacedName()
if config.EnableServicesElection && candidateLease != serviceLease {
continue
}
for _, address := range candidate.Addresses() {
for _, serviceAddress := range addresses {
if address == serviceAddress {
shared[address] = struct{}{}
}
}
}
}
return shared
}
func cleanupWireguardService(tunnelMgr *wireguard.TunnelManager, service *v1.Service) {
if tunnelMgr == nil {
return
}
forEachServiceDNATChain(service, func(ipv6 bool, serviceID string) {
if err := nftables.DeleteIngressChains(ipv6, serviceID); err != nil {
log.Error("[wireguard] failed to delete DNAT chains", "ipv6", ipv6, "service", service.Name, "err", err)
}
})
releaseWireguardServiceTunnels(tunnelMgr, service)
}
type wireguardTunnelReleaser interface {
ReleaseTunnelForVIP(vip, owner string) error
}
func releaseWireguardServiceTunnels(tunnelMgr wireguardTunnelReleaser, service *v1.Service) {
serviceIPs, _ := utils.FetchServiceIPs(service)
for _, serviceIP := range serviceIPs {
if err := tunnelMgr.ReleaseTunnelForVIP(serviceIP, string(service.UID)); err != nil {
log.Error("[wireguard] failed to tear down tunnel", "service", service.Name, "vip", serviceIP, "err", err)
}
}
}

View File

@@ -6,7 +6,6 @@ import (
"net"
"strings"
"sync"
"time"
log "log/slog"
@@ -15,34 +14,39 @@ import (
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/kube-vip/kube-vip/pkg/wireguard"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
)
type Processor struct {
config *kubevip.Config
provider providers.Provider
bgpServer *bgp.Server
worker endpointWorker
instances *[]*instance.Instance
leaseMgr *lease.Manager
config *kubevip.Config
provider providers.Provider
bgpServer *bgp.Server
worker endpointWorker
instances *[]*instance.Instance
instancesMutex *sync.RWMutex
leaseMgr *lease.Manager
lockService func(types.UID) func()
}
func NewEndpointProcessor(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server,
instances *[]*instance.Instance, leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager, routeMgr *route.Manager) *Processor {
instances *[]*instance.Instance, instancesMutex *sync.RWMutex, leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager, routeMgr *route.Manager,
lockService func(types.UID) func()) *Processor {
return &Processor{
config: config,
provider: provider,
bgpServer: bgpServer,
instances: instances,
leaseMgr: leaseMgr,
worker: newEndpointWorker(config, provider, bgpServer, instances, leaseMgr, tunnelMgr, routeMgr),
config: config,
provider: provider,
bgpServer: bgpServer,
instances: instances,
instancesMutex: instancesMutex,
leaseMgr: leaseMgr,
lockService: lockService,
worker: newEndpointWorker(config, provider, bgpServer, leaseMgr, tunnelMgr, routeMgr),
}
}
@@ -53,74 +57,93 @@ func NewEndpointProcessor(config *kubevip.Config, provider providers.Provider, b
// and wait for the next one.
func (p *Processor) Reconcile(svcCtx *servicecontext.Context, event watch.Event,
lastKnownGoodEndpoint *string, service *v1.Service, id string,
serviceFunc func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error, wg *sync.WaitGroup,
wg *sync.WaitGroup,
clientSet *kubernetes.Clientset,
egressUpdateFunc func(context.Context, *v1.Service) error) (bool, error) {
if err := p.applyEvent(svcCtx, event); err != nil {
return false, err
egressUpdateFunc func(context.Context, *v1.Service, *instance.Instance) error) (bool, error) {
if p.lockService == nil {
return false, fmt.Errorf("service operation lock is not configured")
}
endpointCount := 0
var readinessLossGeneration uint64
clearNoEndpoints := false
updatedService, inst, changed, skip, err := func() (*v1.Service, *instance.Instance, bool, bool, error) {
unlockService := p.lockService(service.UID)
defer unlockService()
endpoints, err := p.worker.getEndpoints(service, id)
if err != nil {
return false, fmt.Errorf("[%s] error getting endpoints: %w", p.provider.GetLabel(), err)
}
if err := p.applyEvent(svcCtx, event); err != nil {
return nil, nil, false, false, err
}
if err := p.worker.setInstanceEndpointsStatus(svcCtx.Ctx, service, endpoints); err != nil {
log.Error("updating instance", "err", err)
}
allowReconcileWithoutEndpoints := shouldAllowReconcileWithoutEndpoints(service)
// Find out if we have any local endpoints
// if out endpoint is empty then populate it
// if not, go through the endpoints and see if ours still exists
// If we have a local endpoint then begin the leader Election, unless it's already running
//
// Check that we have local endpoints
if len(endpoints) != 0 {
// Ignore IPv4
endpoints, err := p.worker.getEndpoints(service, id)
if err != nil {
return nil, nil, false, false, fmt.Errorf("[%s] error getting endpoints: %w", p.provider.GetLabel(), err)
}
if service.Annotations[kubevip.EgressIPv6] == "true" && !hasV6(endpoints) {
return true, nil
endpoints = nil
}
endpointCount = len(endpoints)
inst := p.findServiceInstance(service)
if err := p.worker.setInstanceEndpointsStatus(service, inst, endpoints); err != nil {
log.Error("updating instance", "err", err)
}
p.updateLastKnownGoodEndpoint(lastKnownGoodEndpoint, endpoints, service)
allowReconcileWithoutEndpoints := shouldAllowReconcileWithoutEndpoints(service)
if err := p.startServiceHandlingIfNeeded(svcCtx, service, serviceFunc, wg); err != nil {
return true, err
}
if len(endpoints) != 0 {
p.updateLastKnownGoodEndpoint(lastKnownGoodEndpoint, endpoints, service)
svcCtx.SignalReadiness()
if p.shouldProcessInstance() {
if err := p.worker.processInstance(svcCtx, service); err != nil {
return false, fmt.Errorf("failed to process non-empty instance: %w", err)
}
}
} else {
if allowReconcileWithoutEndpoints {
// Explicit opt-in for controllers that create LoadBalancer services without endpoints
if err := p.startServiceHandlingIfNeeded(svcCtx, service, serviceFunc, wg); err != nil {
return true, err
if err := p.startServiceHandlingIfNeeded(svcCtx, service, inst, wg); err != nil {
return nil, nil, false, true, err
}
svcCtx.SignalReadiness()
if p.shouldProcessInstance() {
if err := p.worker.processInstance(svcCtx, service); err != nil {
return false, fmt.Errorf("failed to process endpointless instance: %w", err)
if err := p.worker.processInstance(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, service, inst); err != nil {
return nil, nil, false, false, fmt.Errorf("failed to process non-empty instance: %w", err)
}
}
} else if svcCtx.Signalled.Load() {
p.handleNoEndpoints(svcCtx, service, lastKnownGoodEndpoint)
} else {
if allowReconcileWithoutEndpoints {
// Explicit opt-in for controllers that create LoadBalancer services without endpoints
if err := p.startServiceHandlingIfNeeded(svcCtx, service, inst, wg); err != nil {
return nil, nil, false, true, err
}
svcCtx.SignalReadiness()
if p.shouldProcessInstance() {
if err := p.worker.processInstance(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, service, inst); err != nil {
return nil, nil, false, false, fmt.Errorf("failed to process endpointless instance: %w", err)
}
}
} else if svcCtx.IsReady() {
readinessLossGeneration, _, _, _ = svcCtx.ReadinessState()
clearNoEndpoints = true
}
}
updatedService, changed := p.updateAnnotations(service, inst, lastKnownGoodEndpoint, clientSet)
return updatedService, inst, changed, false, nil
}()
if err != nil || skip {
return skip, err
}
if clearNoEndpoints && svcCtx.ResetReadinessGeneration(readinessLossGeneration) {
unlockService := p.lockService(service.UID)
p.handleNoEndpoints(svcCtx, service, inst, lastKnownGoodEndpoint)
unlockService()
}
if changed && egressUpdateFunc != nil {
if err := egressUpdateFunc(context.Background(), updatedService, inst); err != nil {
log.Error("failed to reconfigure egress", "service", service.Name, "namespace", service.Namespace, "err", err)
}
}
// Set the service accordingly
p.updateAnnotations(service, lastKnownGoodEndpoint, clientSet, egressUpdateFunc)
log.Debug("watcher", "provider",
p.provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace, "endpoints", len(endpoints), "last endpoint", *lastKnownGoodEndpoint)
p.provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace, "endpoints", endpointCount, "last endpoint", *lastKnownGoodEndpoint)
return false, nil
}
@@ -149,13 +172,13 @@ func (p *Processor) shouldProcessInstance() bool {
// handleNoEndpoints tears down everything backing a service that no longer has
// any usable endpoints.
func (p *Processor) handleNoEndpoints(svcCtx *servicecontext.Context, service *v1.Service, lastKnownGoodEndpoint *string) {
svcCtx.ResetReadiness()
p.worker.clear(svcCtx, lastKnownGoodEndpoint, service)
if p.config.EnableARP && !p.config.EnableServicesElection && p.instances != nil {
if i := instance.FindServiceInstance(service, *p.instances); i != nil {
for _, c := range i.Clusters {
c.Stop()
func (p *Processor) handleNoEndpoints(svcCtx *servicecontext.Context, service *v1.Service, inst *instance.Instance, lastKnownGoodEndpoint *string) {
p.worker.clear(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, lastKnownGoodEndpoint, service, inst)
stopWorkers := p.config.EnableARP || (p.config.EnableRoutingTable && p.config.EnableLeaderElection)
if stopWorkers && !p.config.EnableServicesElection {
if inst != nil {
for _, c := range inst.Clusters {
c.StopAndWait()
}
}
}
@@ -195,9 +218,8 @@ func (p *Processor) updateLastKnownGoodEndpoint(lastKnownGoodEndpoint *string, e
}
}
func (p *Processor) updateAnnotations(service *v1.Service, lastKnownGoodEndpoint *string,
clientSet *kubernetes.Clientset,
egressUpdateFunc func(context.Context, *v1.Service) error) {
func (p *Processor) updateAnnotations(service *v1.Service, inst *instance.Instance, lastKnownGoodEndpoint *string,
clientSet *kubernetes.Clientset) (*v1.Service, bool) {
// Set the service accordingly
if service.Annotations[kubevip.Egress] == "true" {
if *lastKnownGoodEndpoint != "" {
@@ -209,7 +231,7 @@ func (p *Processor) updateAnnotations(service *v1.Service, lastKnownGoodEndpoint
"namespace", service.Namespace,
"endpoint", *lastKnownGoodEndpoint,
"expected_ipv6", expectIPv6)
return
return nil, false
}
}
@@ -218,12 +240,11 @@ func (p *Processor) updateAnnotations(service *v1.Service, lastKnownGoodEndpoint
// may have stale annotations if the last update failed
var oldEndpoint, oldEndpointIPv6 string
snapshotFound := false
if p.instances != nil {
serviceInstance := instance.FindServiceInstance(service, *p.instances)
if serviceInstance != nil && serviceInstance.ServiceSnapshot != nil {
if inst != nil {
if inst.ServiceSnapshot != nil {
snapshotFound = true
oldEndpoint = serviceInstance.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint]
oldEndpointIPv6 = serviceInstance.ServiceSnapshot.Annotations[kubevip.ActiveEndpointIPv6]
oldEndpoint = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint]
oldEndpointIPv6 = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpointIPv6]
}
}
// Empty annotations in an existing snapshot are meaningful after a zero-endpoint transition.
@@ -247,7 +268,7 @@ func (p *Processor) updateAnnotations(service *v1.Service, lastKnownGoodEndpoint
// Check if annotation actually changed
annotationChanged := (oldEndpoint != endpoint) || (oldEndpointIPv6 != endpointIPv6)
if !annotationChanged {
return // Nothing to do
return nil, false
}
// Persist to Kubernetes
@@ -255,51 +276,32 @@ func (p *Processor) updateAnnotations(service *v1.Service, lastKnownGoodEndpoint
if err := p.provider.UpdateServiceAnnotation(ctx, endpoint, endpointIPv6, service, clientSet); err != nil {
log.Warn("failed to update service annotation", "service", service.Name, "namespace", service.Namespace, "err", err)
return
return nil, false
}
log.Debug("updated active endpoint annotation", "service", service.Name, "namespace", service.Namespace, "endpoint", *lastKnownGoodEndpoint)
// Trigger egress reconfiguration
// For services with leader election, the service watcher doesn't process Modified events
// after initial setup, so we need to directly call the update function
if egressUpdateFunc != nil {
// Create a copy of service with updated annotations
svcCopy := service.DeepCopy()
svcCopy.Annotations[kubevip.ActiveEndpoint] = endpoint
svcCopy.Annotations[kubevip.ActiveEndpointIPv6] = endpointIPv6
if err := egressUpdateFunc(ctx, svcCopy); err != nil {
log.Error("failed to reconfigure egress", "service", service.Name, "namespace", service.Namespace, "err", err)
}
}
svcCopy := service.DeepCopy()
svcCopy.Annotations[kubevip.ActiveEndpoint] = endpoint
svcCopy.Annotations[kubevip.ActiveEndpointIPv6] = endpointIPv6
return svcCopy, true
}
return nil, false
}
func (p *Processor) startServiceHandlingIfNeeded(svcCtx *servicecontext.Context, service *v1.Service,
serviceFunc func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error, wg *sync.WaitGroup) error {
inst *instance.Instance, wg *sync.WaitGroup) error {
if p.config.EnableServicesElection {
// startLeaderElection restarts itself until the service context is cancelled,
// so start it only once instead of on every endpoint event.
svcCtx.StartLeaderElectionOnce(func() {
wg.Go(func() {
p.startLeaderElection(svcCtx, service, serviceFunc, wg)
})
})
return nil
}
if p.config.EnableARP || (p.config.EnableRoutingTable && p.config.EnableLeaderElection) {
if !svcCtx.Signalled.Load() {
inst := instance.FindServiceInstance(service, *p.instances)
if !svcCtx.IsReady() {
if inst == nil {
return fmt.Errorf("[%s] failed to find an instance for service %s/%s", p.provider.GetLabel(), service.Namespace, service.Name)
}
for x := range inst.VIPConfigs {
log.Debug("starting loadbalancer for service", "provider", p.provider.GetLabel(), "name", service.Name, "namespace", service.Namespace, "uid", service.UID)
if err := inst.Clusters[x].StartLoadBalancerService(svcCtx.Ctx, inst.VIPConfigs[x], p.bgpServer, lease.ServiceNamespacedName(service), wg); err != nil {
return fmt.Errorf("failed to start lb: %w", err)
}
if err := StartService(svcCtx.Ctx, service, inst, p.bgpServer, wg); err != nil {
return fmt.Errorf("start service datapath: %w", err)
}
}
}
@@ -307,44 +309,16 @@ func (p *Processor) startServiceHandlingIfNeeded(svcCtx *servicecontext.Context,
return nil
}
func (p *Processor) startLeaderElection(svcCtx *servicecontext.Context, service *v1.Service, serviceFunc func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error, wg *sync.WaitGroup) {
// Track this loop for the lifetime of the goroutine. There has to be at most
// one per service, so a value above 1 means loops leaked.
loops := metrics.ServiceElectionLoops.WithLabelValues(service.Namespace, service.Name)
loops.Inc()
defer loops.Dec()
attempts := metrics.ServiceElectionAttemptsTotal.WithLabelValues(service.Namespace, service.Name)
// This is a blocking function, that will restart (in the event of failure)
for {
select {
case <-svcCtx.Ctx.Done():
return
default:
leaseNamespace, serviceLease := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, leaseNamespace, serviceLease)
// The lease is retired once its last service is gone, so an absent one means
// this loop has nothing left to elect for.
l := p.leaseMgr.Get(id)
if l == nil {
return
}
l.Lock()
if !l.Elected.Load() {
l.Unlock()
attempts.Inc()
err := serviceFunc(svcCtx, service, wg, true)
if err != nil {
log.Error(err.Error())
}
} else {
l.Unlock()
time.Sleep(time.Millisecond * 200)
}
}
func (p *Processor) findServiceInstance(service *v1.Service) *instance.Instance {
if p.instances == nil {
return nil
}
if p.instancesMutex != nil {
p.instancesMutex.RLock()
defer p.instancesMutex.RUnlock()
}
inst := instance.FindServiceInstance(service, *p.instances)
return inst
}
func shouldAllowReconcileWithoutEndpoints(service *v1.Service) bool {

View File

@@ -3,11 +3,11 @@ package endpoints
import (
"context"
log "log/slog"
"sync"
"github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
)
@@ -23,19 +23,19 @@ func newBGP(generic generic, bgpServer *bgp.Server) endpointWorker {
}
}
func (b *BGP) processInstance(svcCtx *servicecontext.Context, service *v1.Service) error {
if instance := instance.FindServiceInstance(service, *b.instances); instance != nil {
for _, cluster := range instance.Clusters {
func (b *BGP) processInstance(ctx context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error {
if inst != nil {
for _, cluster := range inst.Clusters {
for i := range cluster.Network {
if !svcCtx.IsNetworkConfigured(cluster.Network[i].IP()) {
if _, configured := configuredNetworks.Load(cluster.Network[i].IP()); !configured {
log.Debug("attempting to advertise BGP service", "provider", b.provider.GetLabel(), "ip", cluster.Network[i].IP())
err := b.bgpServer.AddHost(svcCtx.Ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
err := b.bgpServer.AddHost(ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
if err != nil {
log.Error("error adding BGP host", "provider", b.provider.GetLabel(), "err", err)
} else {
log.Info("added BGP host", "provider",
b.provider.GetLabel(), "ip", cluster.Network[i].CIDR(), "service name", service.Name, "namespace", service.Namespace)
svcCtx.ConfiguredNetworks.Store(cluster.Network[i].IP(), true)
configuredNetworks.Store(cluster.Network[i].IP(), true)
}
}
}
@@ -44,19 +44,19 @@ func (b *BGP) processInstance(svcCtx *servicecontext.Context, service *v1.Servic
return nil
}
func (b *BGP) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *string, service *v1.Service) {
func (b *BGP) clear(ctx context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance) {
if !b.config.EnableServicesElection && !b.config.EnableLeaderElection {
// If BGP mode is enabled - routes should be deleted
if instance := instance.FindServiceInstance(service, *b.instances); instance != nil {
for _, cluster := range instance.Clusters {
if inst != nil {
for _, cluster := range inst.Clusters {
for i := range cluster.Network {
err := b.bgpServer.DelHost(svcCtx.Ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
err := b.bgpServer.DelHost(ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
if err != nil {
log.Error("deleting BGP host", "provider", b.provider.GetLabel(), "ip", cluster.Network[i].IP(), "err", err)
} else {
log.Info("deleted BGP host", "provider",
b.provider.GetLabel(), "ip", cluster.Network[i].IP(), "service name", service.Name, "namespace", service.Namespace)
svcCtx.ConfiguredNetworks.Delete(cluster.Network[i].IP())
configuredNetworks.Delete(cluster.Network[i].IP())
}
}
}
@@ -65,15 +65,13 @@ func (b *BGP) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *strin
}
b.clearEgress(lastKnownGoodEndpoint, service)
svcCtx.CallLeaderCancel()
}
func (b *BGP) getEndpoints(service *v1.Service, id string) ([]string, error) {
return b.getAllEndpoints(service, id)
}
func (b *BGP) setInstanceEndpointsStatus(_ context.Context, _ *v1.Service, _ []string) error {
func (b *BGP) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
log "log/slog"
"sync"
"github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/egress"
@@ -12,25 +13,24 @@ import (
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/wireguard"
v1 "k8s.io/api/core/v1"
)
type endpointWorker interface {
processInstance(svcCtx *servicecontext.Context, service *v1.Service) error
clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *string, service *v1.Service)
processInstance(ctx context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error
clear(ctx context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance)
getEndpoints(service *v1.Service, id string) ([]string, error)
removeEgress(service *v1.Service, lastKnownGoodEndpoint *string)
setInstanceEndpointsStatus(ctx context.Context, service *v1.Service, endpoints []string) error
setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error
}
func newEndpointWorker(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server, instances *[]*instance.Instance,
func newEndpointWorker(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server,
leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager, routeMgr *route.Manager) endpointWorker {
generic := newGeneric(config, provider, instances, leaseMgr)
generic := newGeneric(config, provider, leaseMgr)
if config.EnableWireguard {
return newWireguardWorker(config, provider, bgpServer, instances, leaseMgr, tunnelMgr)
return newWireguardWorker(config, provider, tunnelMgr)
}
if config.EnableRoutingTable {
return newRoutingTable(generic, routeMgr)
@@ -43,28 +43,25 @@ func newEndpointWorker(config *kubevip.Config, provider providers.Provider, bgpS
}
type generic struct {
config *kubevip.Config
provider providers.Provider
instances *[]*instance.Instance
leaseMgr *lease.Manager
config *kubevip.Config
provider providers.Provider
leaseMgr *lease.Manager
}
func newGeneric(config *kubevip.Config, provider providers.Provider, instances *[]*instance.Instance, leaseMgr *lease.Manager) generic {
func newGeneric(config *kubevip.Config, provider providers.Provider, leaseMgr *lease.Manager) generic {
return generic{
config: config,
provider: provider,
instances: instances,
leaseMgr: leaseMgr,
config: config,
provider: provider,
leaseMgr: leaseMgr,
}
}
func (g *generic) processInstance(_ *servicecontext.Context, _ *v1.Service) error {
func (g *generic) processInstance(_ context.Context, _ *sync.Map, _ *v1.Service, _ *instance.Instance) error {
return nil
}
func (g *generic) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *string, service *v1.Service) {
func (g *generic) clear(_ context.Context, _ *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, _ *instance.Instance) {
g.clearEgress(lastKnownGoodEndpoint, service)
svcCtx.CallLeaderCancel()
}
func (g *generic) clearEgress(lastKnownGoodEndpoint *string, service *v1.Service) {
@@ -102,6 +99,6 @@ func (g *generic) getAllEndpoints(service *v1.Service, id string) ([]string, err
func (g *generic) removeEgress(_ *v1.Service, _ *string) {
}
func (g *generic) setInstanceEndpointsStatus(_ context.Context, _ *v1.Service, _ []string) error {
func (g *generic) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
return nil
}

View File

@@ -12,13 +12,11 @@ import (
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
)
type RoutingTable struct {
generic
mtx sync.Mutex
routeMgr *route.Manager
}
@@ -29,19 +27,18 @@ func newRoutingTable(generic generic, routeMgr *route.Manager) endpointWorker {
}
}
func (rt *RoutingTable) processInstance(svcCtx *servicecontext.Context, service *v1.Service) error {
inst := instance.FindServiceInstance(service, *rt.instances)
func (rt *RoutingTable) processInstance(_ context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error {
if inst != nil {
for _, cluster := range inst.Clusters {
for i := range cluster.Network {
if !svcCtx.IsNetworkConfigured(cluster.Network[i].IP()) && cluster.Network[i].HasEndpoints() {
if _, configured := configuredNetworks.Load(cluster.Network[i].IP()); !configured && cluster.Network[i].HasEndpoints() {
if err := rt.routeMgr.Add(lease.ServiceNamespacedName(service), cluster.Network[i], false, true); err != nil {
return fmt.Errorf("[%s] error adding route: %s", rt.provider.GetLabel(), err.Error())
} else {
log.Info("added route", "provider",
rt.provider.GetLabel(), "ip", cluster.Network[i].IP(), "service name", service.Name, "namespace",
service.Namespace, "interface", cluster.Network[i].Interface(), "tableID", rt.config.RoutingTableID)
svcCtx.ConfiguredNetworks.Store(cluster.Network[i].IP(), true)
configuredNetworks.Store(cluster.Network[i].IP(), true)
}
}
}
@@ -51,12 +48,10 @@ func (rt *RoutingTable) processInstance(svcCtx *servicecontext.Context, service
return nil
}
func (rt *RoutingTable) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *string, service *v1.Service) {
rt.mtx.Lock()
defer rt.mtx.Unlock()
func (rt *RoutingTable) clear(_ context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance) {
if !rt.config.EnableServicesElection {
if errs := ClearRoutes(service, rt.instances, rt.routeMgr); len(errs) == 0 {
svcCtx.ConfiguredNetworks.Clear()
if errs := ClearRoutesByInstance(service, inst, nil, rt.routeMgr); len(errs) == 0 {
configuredNetworks.Clear()
} else {
for _, err := range errs {
log.Error("error while clearing routes", "err", err)
@@ -65,8 +60,6 @@ func (rt *RoutingTable) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpo
}
rt.clearEgress(lastKnownGoodEndpoint, service)
svcCtx.CallLeaderCancel()
}
func (rt *RoutingTable) getEndpoints(service *v1.Service, id string) ([]string, error) {
@@ -80,8 +73,7 @@ func (rt *RoutingTable) removeEgress(service *v1.Service, lastKnownGoodEndpoint
}
}
func (rt *RoutingTable) setInstanceEndpointsStatus(ctx context.Context, service *v1.Service, endpoints []string) error {
inst := instance.FindServiceInstance(service, *rt.instances)
func (rt *RoutingTable) setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error {
if inst == nil {
log.Error("failed to find the instance", "namespace", service.Namespace, "name", service.Name, "uid", service.UID, "provider", rt.provider.GetLabel())
} else {

View File

@@ -3,20 +3,19 @@ package endpoints
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/cluster"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/prometheus/client_golang/prometheus/testutil"
v1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
)
@@ -47,6 +46,7 @@ type fakeWorker struct {
endpoints []string
clearCalled bool
processCalled bool
processHook func()
}
type annotationUpdate struct {
@@ -111,15 +111,16 @@ func TestUpdateAnnotationsZeroEndpointsThenSameEndpoint(t *testing.T) {
instances: &instances,
}
updateSnapshot := func(_ context.Context, updated *v1.Service) error {
serviceInstance.ServiceSnapshot = updated
return nil
}
noEndpoint := ""
processor.updateAnnotations(service, &noEndpoint, nil, updateSnapshot)
updated, changed := processor.updateAnnotations(service, serviceInstance, &noEndpoint, nil)
if changed {
serviceInstance.ServiceSnapshot = updated
}
repopulatedEndpoint := family.endpoint
processor.updateAnnotations(service, &repopulatedEndpoint, nil, updateSnapshot)
updated, changed = processor.updateAnnotations(service, serviceInstance, &repopulatedEndpoint, nil)
if changed {
serviceInstance.ServiceSnapshot = updated
}
cleared := annotationUpdate{}
repopulated := annotationUpdate{endpoint: family.endpoint}
@@ -176,7 +177,7 @@ func TestUpdateAnnotationsEndpointSlicesClearsConfiguredFamily(t *testing.T) {
}
noEndpoint := ""
processor.updateAnnotations(service, &noEndpoint, nil, func(context.Context, *v1.Service) error { return nil })
processor.updateAnnotations(service, instances[0], &noEndpoint, nil)
if len(recorder.updates) != 1 || recorder.updates[0] != test.want {
t.Fatalf("annotation updates = %+v, want [%+v]", recorder.updates, test.want)
@@ -219,7 +220,7 @@ func TestUpdateAnnotationsValidatesEndpointFamily(t *testing.T) {
provider: recorder,
}
processor.updateAnnotations(service, &test.endpoint, nil, nil)
processor.updateAnnotations(service, nil, &test.endpoint, nil)
if !test.wantUpdate {
if len(recorder.updates) != 0 {
@@ -234,21 +235,28 @@ func TestUpdateAnnotationsValidatesEndpointFamily(t *testing.T) {
}
}
func (f *fakeWorker) processInstance(_ *servicecontext.Context, _ *v1.Service) error {
func (f *fakeWorker) processInstance(_ context.Context, _ *sync.Map, _ *v1.Service, _ *instance.Instance) error {
if f.processHook != nil {
f.processHook()
}
f.processCalled = true
return nil
}
func (f *fakeWorker) clear(_ *servicecontext.Context, _ *string, _ *v1.Service) {
func (f *fakeWorker) clear(_ context.Context, _ *sync.Map, _ *string, _ *v1.Service, _ *instance.Instance) {
f.clearCalled = true
}
func (f *fakeWorker) getEndpoints(_ *v1.Service, _ string) ([]string, error) { return f.endpoints, nil }
func (f *fakeWorker) removeEgress(_ *v1.Service, _ *string) {}
func (f *fakeWorker) setInstanceEndpointsStatus(_ context.Context, _ *v1.Service, _ []string) error {
func (f *fakeWorker) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
return nil
}
func noOpServiceLock(types.UID) func() {
return func() {}
}
// TestReconcile_RecomputesRemainingEndpoints asserts that deleting one EndpointSlice
// reconciles against the endpoints that remain, instead of assuming the service
// lost all of them.
@@ -293,9 +301,10 @@ func TestReconcile_RecomputesRemainingEndpoints(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
worker := &fakeWorker{endpoints: test.remaining}
p := &Processor{
config: &kubevip.Config{},
provider: providers.NewEndpointslices(),
worker: worker,
config: &kubevip.Config{},
provider: providers.NewEndpointslices(),
worker: worker,
lockService: noOpServiceLock,
}
svcCtx := servicecontext.New(context.Background())
@@ -311,7 +320,6 @@ func TestReconcile_RecomputesRemainingEndpoints(t *testing.T) {
&lastKnown,
&v1.Service{Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal}},
"node-1",
func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error { return nil },
&sync.WaitGroup{},
nil,
nil,
@@ -323,7 +331,7 @@ func TestReconcile_RecomputesRemainingEndpoints(t *testing.T) {
t.Fatal("Reconcile unexpectedly requested restart")
}
if ready := svcCtx.Signalled.Load(); ready != test.expectReady {
if ready := svcCtx.IsReady(); ready != test.expectReady {
t.Fatalf("readiness mismatch: expected %v, got %v", test.expectReady, ready)
}
if worker.clearCalled != test.expectClear {
@@ -347,9 +355,10 @@ func TestReconcile_ZeroEndpointsBehavior(t *testing.T) {
worker := &fakeWorker{endpoints: []string{}}
p := &Processor{
config: &kubevip.Config{},
provider: providers.NewEndpointslices(),
worker: worker,
config: &kubevip.Config{},
provider: providers.NewEndpointslices(),
worker: worker,
lockService: noOpServiceLock,
}
svcCtx := servicecontext.New(context.Background())
@@ -363,7 +372,6 @@ func TestReconcile_ZeroEndpointsBehavior(t *testing.T) {
new(string),
service,
"node-1",
func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error { return nil },
&sync.WaitGroup{},
nil,
nil,
@@ -375,7 +383,7 @@ func TestReconcile_ZeroEndpointsBehavior(t *testing.T) {
t.Fatal("Reconcile unexpectedly requested restart")
}
if ready := svcCtx.Signalled.Load(); ready != expectReady {
if ready := svcCtx.IsReady(); ready != expectReady {
t.Fatalf("readiness mismatch: expected %v, got %v", expectReady, ready)
}
if worker.clearCalled != expectClear {
@@ -411,15 +419,106 @@ func TestReconcile_ZeroEndpointsBehavior(t *testing.T) {
})
}
// TestReconcile_ServicesElectionStartsOnce asserts that repeated endpoint events
// for the same service start the leader-election restart loop exactly once.
//
// Reconcile runs on every EndpointSlice add/modify/resync event, and the loop it
// starts only returns once the service context is cancelled. Starting it per event
// therefore accumulates duplicate goroutines that all contend on the same lease.
//
// See https://github.com/kube-vip/kube-vip/issues/1665.
func TestReconcile_ServicesElectionStartsOnce(t *testing.T) {
func TestHandleNoEndpointsStopsGlobalRoutingTableWorkers(t *testing.T) {
config := &kubevip.Config{EnableRoutingTable: true, KubernetesLeaderElection: kubevip.KubernetesLeaderElection{EnableLeaderElection: true}}
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, route.NewManager(), nil)
if err != nil {
t.Fatalf("initializing Service cluster: %v", err)
}
var workers sync.WaitGroup
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("starting Service cluster: %v", err)
}
instance := &instance.Instance{Clusters: []*cluster.Cluster{serviceCluster}}
processor := &Processor{config: config, worker: &fakeWorker{}}
processor.handleNoEndpoints(servicecontext.New(context.Background()), &v1.Service{}, instance, new(string))
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
t.Fatalf("starting Service cluster after endpoint loss: %v", err)
}
serviceCluster.StopAndWait()
workers.Wait()
}
func TestReconcileIPv6EgressWithoutIPv6EndpointsClearsReadiness(t *testing.T) {
worker := &fakeWorker{endpoints: []string{"192.0.2.10"}}
processor := &Processor{
config: &kubevip.Config{},
provider: providers.NewEndpointslices(),
worker: worker,
lockService: noOpServiceLock,
}
service := &v1.Service{
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.EgressIPv6: "true"}},
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal},
}
svcCtx := servicecontext.New(context.Background())
svcCtx.SignalReadiness()
restart, err := processor.Reconcile(svcCtx, watch.Event{Type: watch.Modified, Object: &discoveryv1.EndpointSlice{}},
new(string), service, "node", &sync.WaitGroup{}, nil, nil)
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if restart {
t.Fatal("Reconcile requested a restart for an unusable endpoint family")
}
if svcCtx.IsReady() {
t.Fatal("IPv6 egress remained ready with only IPv4 endpoints")
}
if !worker.clearCalled {
t.Fatal("IPv6 egress did not clear the worker with only IPv4 endpoints")
}
}
func TestSharedServiceVIPRequiresClusterPolicyAndElectionLease(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
candidate := service.DeepCopy()
candidate.Name = "second"
first := &instance.Instance{ServiceSnapshot: service}
second := &instance.Instance{ServiceSnapshot: candidate}
if len(sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, first, []*instance.Instance{second})) == 0 {
t.Fatal("shared lease Services with Cluster traffic policy did not share their VIP")
}
if len(sharedServiceVIPs(&kubevip.Config{}, first, []*instance.Instance{second})) == 0 {
t.Fatal("Cluster policy Services without per-Service election did not share their VIP")
}
candidate.Spec.ExternalTrafficPolicy = v1.ServiceExternalTrafficPolicyTypeLocal
if len(sharedServiceVIPs(&kubevip.Config{}, first, []*instance.Instance{second})) != 0 {
t.Fatal("Local traffic policy Services shared a VIP")
}
candidate.Spec.ExternalTrafficPolicy = v1.ServiceExternalTrafficPolicyTypeCluster
candidate.Annotations[kubevip.ServiceLease] = "different"
if len(sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, first, []*instance.Instance{second})) != 0 {
t.Fatal("Services with different leases shared a VIP")
}
}
func TestSharedServiceVIPsReturnsOnlyOverlappingAddresses(t *testing.T) {
first := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
second := first.DeepCopy()
second.Name = "second"
second.Spec.LoadBalancerIP = "192.0.2.11"
firstInstance := &instance.Instance{ServiceSnapshot: first, ServiceAddresses: []string{"192.0.2.10", "2001:db8::10"}}
secondInstance := &instance.Instance{ServiceSnapshot: second, ServiceAddresses: []string{"192.0.2.10", "2001:db8::11"}}
shared := sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, firstInstance, []*instance.Instance{secondInstance})
if len(shared) != 1 {
t.Fatalf("shared VIPs = %v, want exactly one", shared)
}
if _, found := shared["192.0.2.10"]; !found {
t.Fatalf("shared VIPs = %v, missing overlapping IPv4 address", shared)
}
}
// TestReconcileServicesElectionDoesNotStartElectionLoop asserts endpoint events
// only update readiness. The services coordinator owns the election loop.
func TestReconcileServicesElectionDoesNotStartElectionLoop(t *testing.T) {
config := &kubevip.Config{
EnableServicesElection: true,
LeaderElectionType: "kubernetes",
@@ -439,32 +538,22 @@ func TestReconcile_ServicesElectionStartsOnce(t *testing.T) {
svcCtx := servicecontext.New(svcLease.Ctx)
// The started loops only return once the service context is cancelled, so it has
// to be cancelled before waiting on them.
wg := &sync.WaitGroup{}
defer wg.Wait()
defer svcCtx.Cancel()
p := &Processor{
config: config,
provider: providers.NewEndpointslices(),
worker: &fakeWorker{endpoints: []string{"10.0.0.1"}},
leaseMgr: leaseMgr,
}
// starts counts the restart loops. The real StartServicesLeaderElection blocks
// until the service context is cancelled, so each loop parks in a single call.
var starts atomic.Int64
serviceFunc := func(svcCtx *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, _ bool) error {
starts.Add(1)
<-svcCtx.Ctx.Done()
return nil
config: config,
provider: providers.NewEndpointslices(),
worker: &fakeWorker{endpoints: []string{"10.0.0.1"}},
leaseMgr: leaseMgr,
lockService: noOpServiceLock,
}
// Three endpoint events, as a flapping backend pod would produce.
for range 3 {
restart, err := p.Reconcile(svcCtx, watch.Event{Type: watch.Modified, Object: &discoveryv1.EndpointSlice{}},
new(string), service, "node-1", serviceFunc, wg, nil, nil)
new(string), service, "node-1", wg, nil, nil)
if err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
@@ -473,20 +562,18 @@ func TestReconcile_ServicesElectionStartsOnce(t *testing.T) {
}
}
// Give every loop that is going to start a chance to reach serviceFunc.
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
if starts.Load() > 1 {
break
}
time.Sleep(10 * time.Millisecond)
generation, ready, lost, isReady := svcCtx.ReadinessState()
if generation != 1 || !isReady {
t.Fatalf("readiness state = generation %d, ready %t; want generation 1 ready", generation, isReady)
}
if got := starts.Load(); got != 1 {
t.Errorf("leader election started %d times, want 1", got)
select {
case <-ready:
default:
t.Fatal("endpoint reconciliation did not signal readiness")
}
// The gauge the e2e fault tests assert on has to agree with the call count.
if got := testutil.ToFloat64(metrics.ServiceElectionLoops.WithLabelValues(service.Namespace, service.Name)); got != 1 {
t.Errorf("kube_vip_service_election_loops is %v, want 1", got)
select {
case <-lost:
t.Fatal("endpoint reconciliation unexpectedly reset readiness")
default:
}
}

View File

@@ -3,16 +3,14 @@ package endpoints
import (
"context"
"fmt"
"sync"
log "log/slog"
"github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/nftables"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/kube-vip/kube-vip/pkg/wireguard"
v1 "k8s.io/api/core/v1"
@@ -22,27 +20,20 @@ import (
type wireguardWorker struct {
config *kubevip.Config
provider providers.Provider
bgpServer *bgp.Server
instances *[]*instance.Instance
leaseMgr *lease.Manager
tunnelMgr *wireguard.TunnelManager
}
func newWireguardWorker(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server,
instances *[]*instance.Instance, leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager) *wireguardWorker {
func newWireguardWorker(config *kubevip.Config, provider providers.Provider, tunnelMgr *wireguard.TunnelManager) *wireguardWorker {
return &wireguardWorker{
config: config,
provider: provider,
bgpServer: bgpServer,
instances: instances,
leaseMgr: leaseMgr,
tunnelMgr: tunnelMgr,
}
}
// processInstance updates nftables DNAT rules when endpoints change
// This is called by the endpoint watcher when endpoints are added/modified
func (w *wireguardWorker) processInstance(svcCtx *servicecontext.Context, service *v1.Service) error {
func (w *wireguardWorker) processInstance(ctx context.Context, _ *sync.Map, service *v1.Service, inst *instance.Instance) error {
log.Debug("[wireguard] processing instance for endpoint change", "service", service.Name, "namespace", service.Namespace)
// Get the target endpoint for this service
@@ -61,25 +52,21 @@ func (w *wireguardWorker) processInstance(svcCtx *servicecontext.Context, servic
if len(endpoints) == 0 {
log.Debug("[wireguard] no endpoints available", "service", service.Name)
w.clear(svcCtx, nil, service)
w.clear(ctx, nil, nil, service, inst)
return nil
}
// Find the service processor to call updateServiceWireguardEndpoints
// Note: This requires access to the service processor which we don't have here
// So we'll recreate the DNAT rules directly
// First, clear existing rules
w.clear(svcCtx, nil, service)
// Get service VIPs
serviceIPs, err := utils.FetchServiceIPs(service)
if err != nil {
return fmt.Errorf("failed to get service IPs: %w", err)
}
// Create service identifier
serviceID := utils.SanitizeServiceID(fmt.Sprintf("%s_%s", service.Namespace, service.Name))
if len(service.Spec.Ports) != 0 {
if err := w.ensureTunnels(service, serviceIPs); err != nil {
return err
}
}
w.clearDNAT(service)
log.Info("[wireguard] updating DNAT rules for endpoint change",
"service", service.Name,
@@ -87,133 +74,124 @@ func (w *wireguardWorker) processInstance(svcCtx *servicecontext.Context, servic
"endpoints", endpoints,
"vips", serviceIPs)
// Update DNAT rules for each port
for _, port := range service.Spec.Ports {
// Determine target port (resolve named ports if necessary)
if port.Protocol != v1.ProtocolTCP && port.Protocol != v1.ProtocolUDP {
continue
}
targetPort := w.provider.ResolvePort(port)
log.Info("[wireguard] resolved port", "service", service.Name, "servicePort", port.Port, "targetPort", targetPort, "targetPortName", port.TargetPort.StrVal)
// Build targets list from all endpoints
targets := make([]nftables.DNATTarget, len(endpoints))
for i, ep := range endpoints {
targets[i] = nftables.DNATTarget{
IP: ep,
for index, endpoint := range endpoints {
targets[index] = nftables.DNATTarget{
IP: endpoint,
Port: uint16(targetPort), //nolint:gosec // Port range validated by Kubernetes
}
}
portServiceID, _ := wireguard.ServicePortIDs(service.Namespace, service.Name, port)
for _, vip := range serviceIPs {
// Strip CIDR notation if present
vipAddr := utils.StripCIDR(vip)
// Get WireGuard interface name from TunnelManager for this VIP
for _, serviceIP := range serviceIPs {
vipAddress := utils.StripCIDR(serviceIP)
if w.tunnelMgr == nil {
log.Error("[wireguard] TunnelManager not configured; cannot update DNAT rules",
"service", service.Name,
"namespace", service.Namespace)
return fmt.Errorf("TunnelManager not configured")
return fmt.Errorf("WireGuard tunnel manager not configured")
}
tunnelConfig := w.tunnelMgr.GetConfigForVIP(vipAddr)
tunnelConfig := w.tunnelMgr.GetConfigForVIP(vipAddress)
if tunnelConfig == nil {
log.Error("[wireguard] WireGuard interface name not configured; cannot update DNAT rules",
"service", service.Name,
"namespace", service.Namespace,
"vip", vipAddr)
return fmt.Errorf("wireguard interface name not configured for VIP %s", vipAddr)
return fmt.Errorf("wireguard interface name not configured for VIP %s", vipAddress)
}
wgInterface := tunnelConfig.InterfaceName
portServiceID := fmt.Sprintf("%s_p%d", serviceID, port.Port)
log.Info("[wireguard] applying DNAT rule with load balancing",
"service", service.Name,
"vip", vipAddr,
"interface", wgInterface,
"sourcePort", port.Port,
"targets", targets,
"chainID", portServiceID)
// Apply the DNAT rule with load balancing across all endpoints
// localEndpoint=true when using ExternalTrafficPolicy=Local, which preserves client source IP
isLocalEndpoint := service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal
err := nftables.ApplyDNAT(
wgInterface,
vipAddr,
if err := nftables.ApplyDNAT(
tunnelConfig.InterfaceName,
vipAddress,
uint16(port.Port), //nolint:gosec // Port range validated by Kubernetes
targets,
portServiceID,
port.Protocol,
isLocalEndpoint,
service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal,
tunnelConfig.ListenPort,
)
if err != nil {
log.Error("[wireguard] failed to update DNAT rule",
"service", service.Name,
"vip", vipAddr,
"port", port.Port,
"err", err)
); err != nil {
log.Error("[wireguard] failed to update DNAT rule", "service", service.Name, "vip", vipAddress, "port", port.Port, "err", err)
continue
}
log.Debug("[wireguard] DNAT rule updated successfully",
"service", service.Name,
"vip", vipAddr,
"port", port.Port,
"targetCount", len(targets))
}
}
return nil
}
func (w *wireguardWorker) ensureTunnels(service *v1.Service, serviceIPs []string) error {
if w.tunnelMgr == nil {
return fmt.Errorf("WireGuard tunnel manager not configured")
}
if len(serviceIPs) == 0 {
return fmt.Errorf("no service IPs found for service %s/%s", service.Namespace, service.Name)
}
var successCount int
var lastErr error
for _, serviceIP := range serviceIPs {
if !w.tunnelMgr.HasConfigForVIP(serviceIP) {
lastErr = fmt.Errorf("no WireGuard tunnel configuration found for VIP %s", serviceIP)
continue
}
if err := w.tunnelMgr.AcquireTunnelForVIP(serviceIP, string(service.UID)); err != nil {
lastErr = fmt.Errorf("bring up WireGuard tunnel for VIP %s: %w", serviceIP, err)
continue
}
successCount++
}
if successCount == 0 {
return fmt.Errorf("failed to setup WireGuard tunnel for any VIP in service %s/%s: %w", service.Namespace, service.Name, lastErr)
}
return nil
}
// clear removes DNAT rules when no endpoints are available
func (w *wireguardWorker) clear(svcCtx *servicecontext.Context, lastKnownGoodEndpoint *string, service *v1.Service) {
func (w *wireguardWorker) clear(_ context.Context, _ *sync.Map, _ *string, service *v1.Service, _ *instance.Instance) {
w.clearDNAT(service)
}
func (w *wireguardWorker) clearDNAT(service *v1.Service) {
log.Info("[wireguard] clearing DNAT rules (no endpoints)", "service", service.Name, "namespace", service.Namespace)
forEachServiceDNATChain(service, func(ipv6 bool, serviceID string) {
if err := nftables.DeleteIngressChains(ipv6, serviceID); err != nil {
family := utils.IPv4Family
if ipv6 {
family = utils.IPv6Family
}
log.Warn("[wireguard] failed to delete DNAT chains", "family", family, "service", service.Name, "err", err)
}
})
}
serviceID := utils.SanitizeServiceID(fmt.Sprintf("%s_%s", service.Namespace, service.Name))
// Get service IPs to determine IPv4 vs IPv6
func forEachServiceDNATChain(service *v1.Service, visit func(bool, string)) {
if service == nil {
return
}
serviceIPs, _ := utils.FetchServiceIPs(service)
familyUnknown := len(serviceIPs) == 0
hasIPv4, hasIPv6 := familyUnknown, familyUnknown
for _, serviceIP := range serviceIPs {
if isIPv6Address(serviceIP) {
hasIPv6 = true
} else {
hasIPv4 = true
}
}
// Delete DNAT chains for each port
for _, port := range service.Spec.Ports {
if port.Protocol != v1.ProtocolTCP && port.Protocol != v1.ProtocolUDP {
continue
}
portServiceID := fmt.Sprintf("%s_p%d", serviceID, port.Port)
// Determine if we have IPv4 or IPv6
hasIPv4, hasIPv6 := false, false
for _, vip := range serviceIPs {
if isIPv6Address(vip) {
hasIPv6 = true
} else {
hasIPv4 = true
portServiceID, legacyServiceID := wireguard.ServicePortIDs(service.Namespace, service.Name, port)
// The legacy identifier is visited so an upgrade removes chains written
// before rule IDs carried the protocol.
for _, serviceID := range []string{portServiceID, legacyServiceID} {
if hasIPv4 {
visit(false, serviceID)
}
if hasIPv6 {
visit(true, serviceID)
}
}
if hasIPv4 {
if err := nftables.DeleteIngressChains(false, portServiceID); err != nil {
log.Warn("[wireguard] failed to delete IPv4 DNAT chains",
"service", service.Name,
"port", port.Port,
"err", err)
}
}
if hasIPv6 {
if err := nftables.DeleteIngressChains(true, portServiceID); err != nil {
log.Warn("[wireguard] failed to delete IPv6 DNAT chains",
"service", service.Name,
"port", port.Port,
"err", err)
}
}
}
if svcCtx != nil {
svcCtx.CallLeaderCancel()
}
}
@@ -243,7 +221,7 @@ func (w *wireguardWorker) removeEgress(service *v1.Service, lastKnownGoodEndpoin
}
// setInstanceEndpointsStatus updates the endpoint status on the service instance
func (w *wireguardWorker) setInstanceEndpointsStatus(_ context.Context, service *v1.Service, endpoints []string) error {
func (w *wireguardWorker) setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error {
hasEndpoints := len(endpoints) > 0
log.Debug("[wireguard] setting instance endpoint status",
@@ -251,23 +229,17 @@ func (w *wireguardWorker) setInstanceEndpointsStatus(_ context.Context, service
"hasEndpoints", hasEndpoints,
"endpointCount", len(endpoints))
// Find the service instance
for _, inst := range *w.instances {
if inst.ServiceSnapshot == nil {
continue
}
if inst.ServiceSnapshot.UID == service.UID {
// Update the network status for all clusters
for _, cluster := range inst.Clusters {
for i := range cluster.Network {
cluster.Network[i].SetHasEndpoints(hasEndpoints)
}
if inst != nil {
// Update the network status for all clusters
for _, cluster := range inst.Clusters {
for i := range cluster.Network {
cluster.Network[i].SetHasEndpoints(hasEndpoints)
}
log.Debug("[wireguard] updated instance endpoint status",
"service", service.Name,
"hasEndpoints", hasEndpoints)
return nil
}
log.Debug("[wireguard] updated instance endpoint status",
"service", service.Name,
"hasEndpoints", hasEndpoints)
return nil
}
log.Debug("[wireguard] instance not found for endpoint status update", "service", service.Name)

View File

@@ -1,14 +1,41 @@
package endpoints
import (
"context"
"fmt"
"testing"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
type recordingTunnelReleaser struct {
releases []string
}
func (r *recordingTunnelReleaser) ReleaseTunnelForVIP(vip, owner string) error {
r.releases = append(r.releases, fmt.Sprintf("%s:%s", vip, owner))
return nil
}
func TestWireguardClearDoesNotDereferenceNilServiceContext(t *testing.T) {
worker := &wireguardWorker{}
service := &v1.Service{}
worker.clear(nil, nil, service)
worker.clear(context.TODO(), nil, nil, service, nil)
}
func TestReleaseWireguardServiceTunnelsUsesServiceUIDOwner(t *testing.T) {
releaser := &recordingTunnelReleaser{}
service := &v1.Service{
ObjectMeta: metav1.ObjectMeta{UID: types.UID("service-uid")},
Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10"},
}
releaseWireguardServiceTunnels(releaser, service)
if len(releaser.releases) != 1 || releaser.releases[0] != "192.0.2.10:service-uid" {
t.Fatalf("tunnel releases = %v, want [192.0.2.10:service-uid]", releaser.releases)
}
}

View File

@@ -274,6 +274,8 @@ func (instance *Instance) initialize(ctx context.Context, svc *v1.Service, confi
DHCPBackoffAttempts: config.DHCPBackoffAttempts,
DisableServiceUpdates: config.DisableServiceUpdates,
EnableServicesElection: config.EnableServicesElection,
// cleanupVIPs reads this from the per-VIP config, so Service VIPs need it too.
PreserveVIPOnLeadershipLoss: config.PreserveVIPOnLeadershipLoss,
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
EnableLeaderElection: config.EnableLeaderElection,
},

View File

@@ -26,17 +26,37 @@ func NewManager() *Manager {
}
}
// Add adds lease to the manager.
// It returns three values:
// - lease for the object
// - isNewObject, which reports if it is a new object that is being handled
// - isSharedLease, which is true if object shares the lease with another object
// If object is new but not shared, we should start leaderelection and sync it
// If object is new and shared, we should only sync it as the leaderelection should be already handled
// If object is not new we should do nothing
// Add creates or retrieves the lease identified by id.
func (m *Manager) Add(ctx context.Context, id ID) *Lease {
m.lock.Lock()
defer m.lock.Unlock()
return m.addLocked(ctx, id)
}
// Acquire creates or retrieves a lease and atomically registers objectName as a
// member. The returned bool reports whether this object was newly registered.
func (m *Manager) Acquire(ctx context.Context, id ID, objectName string) (*Lease, bool) {
m.lock.Lock()
defer m.lock.Unlock()
lease := m.addLocked(ctx, id)
return lease, lease.Add(objectName)
}
// Claim atomically registers objectName against an existing lease. It returns
// nil when the lease was retired before the caller could join it.
func (m *Manager) Claim(id ID, objectName string) (*Lease, bool) {
m.lock.Lock()
defer m.lock.Unlock()
lease, exists := m.leases[id.NamespacedName()]
if !exists {
return nil, false
}
return lease, lease.Add(objectName)
}
func (m *Manager) addLocked(ctx context.Context, id ID) *Lease {
// A lease whose context is already cancelled cannot be handed out again:
// anything derived from it would be cancelled straight away. Replace it.
@@ -49,8 +69,8 @@ func (m *Manager) Add(ctx context.Context, id ID) *Lease {
}
// Delete removes the object from the lease it was added to and cancels that lease
// once its last object is gone. With a common lease, the siblings that still use
// it keep it alive.
// once its last object is gone. It reports whether the lease was retired. With a
// common lease, the siblings that still use it keep it alive.
//
// The lease the caller was given has to be passed in, because cleanup is usually
// deferred to a goroutine that runs long after the object went away. By then the
@@ -62,19 +82,21 @@ func (m *Manager) Add(ctx context.Context, id ID) *Lease {
// deferred cleanup: until the lease is out of the map, Add hands the same
// instance back, so a service that is rebuilt straight away gets parented to a
// lease that the pending cleanup is about to cancel.
func (m *Manager) Delete(id ID, objectName string, l *Lease) {
func (m *Manager) Delete(id ID, objectName string, l *Lease) bool {
m.lock.Lock()
defer m.lock.Unlock()
current := m.currentFor(id, l)
if current == nil {
return
return false
}
current.delete(objectName)
if current.cnt.Load() < 1 {
m.retire(id, current)
return true
}
return false
}
// currentFor returns the registered lease for id, or nil when the caller is
@@ -110,19 +132,32 @@ func (m *Manager) Get(id ID) *Lease {
type Lease struct {
Ctx context.Context
Cancel context.CancelFunc
Started chan any
services sync.Map
cnt atomic.Int64
Elected atomic.Bool
Mtx sync.Mutex
locked bool
stateMu sync.Mutex
running bool
ended uint64
changed chan struct{}
}
func newLease(ctx context.Context, cancel context.CancelFunc) *Lease {
return &Lease{
Ctx: ctx,
Cancel: cancel,
Started: make(chan any),
changed: make(chan struct{}),
}
}
// NewElectionContext returns a context for one election runner. Cancelling it
// stops only that runner; the Lease context remains live until its final member
// is deleted from the Manager.
func (l *Lease) NewElectionContext(parent context.Context) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(l.Ctx)
stopParent := context.AfterFunc(parent, cancel)
return ctx, func() {
stopParent()
cancel()
}
}
@@ -136,35 +171,125 @@ func (l *Lease) Add(name string) bool {
return false
}
// delete removes the service from the lease and decrements the counter
// delete removes the service from the lease and decrements the counter.
func (l *Lease) delete(service string) {
if _, exists := l.services.Load(service); exists {
l.services.Delete(service)
if _, exists := l.services.LoadAndDelete(service); exists {
l.cnt.Add(-1)
}
}
func (l *Lease) Lock() {
l.Mtx.Lock()
l.locked = true
func (l *Lease) BeginElection() bool {
l.stateMu.Lock()
defer l.stateMu.Unlock()
if l.Elected.Load() || l.running {
return false
}
l.running = true
l.signalStateLocked()
return true
}
func (l *Lease) Unlock() {
if l.locked {
l.locked = false
l.Mtx.Unlock()
func (l *Lease) ElectionStarted() {
l.stateMu.Lock()
defer l.stateMu.Unlock()
if l.Elected.Load() {
return
}
l.Elected.Store(true)
l.running = false
l.signalStateLocked()
}
func (l *Lease) ElectionStopped() {
l.stateMu.Lock()
defer l.stateMu.Unlock()
if !l.Elected.Load() && !l.running {
return
}
l.Elected.Store(false)
l.running = false
l.ended++
l.signalStateLocked()
}
// WaitForLeader waits for an in-flight lease election to either elect a leader
// or finish without one. It never holds the lease state mutex while waiting.
func (l *Lease) WaitForLeader(ctx context.Context) bool {
_, elected := l.WaitForLeaderGeneration(ctx)
return elected
}
// WaitForLeaderGeneration waits for leadership and returns the election-end
// generation observed atomically with the elected state.
func (l *Lease) WaitForLeaderGeneration(ctx context.Context) (uint64, bool) {
for {
elected, running, changed, ended := l.state()
if elected {
return ended, true
}
if !running {
return 0, false
}
select {
case <-ctx.Done():
return 0, false
case <-l.Ctx.Done():
return 0, false
case <-changed:
}
}
}
// WaitForElectionEnd waits until an elected lease loses its leader. It never
// holds the lease state mutex while waiting.
func (l *Lease) WaitForElectionEnd(ctx context.Context) {
_, _, _, initialEnded := l.state()
l.WaitForElectionEndAfter(ctx, initialEnded)
}
// WaitForElectionEndAfter waits until the leadership generation returned by
// WaitForLeaderGeneration ends, even if a replacement election starts first.
func (l *Lease) WaitForElectionEndAfter(ctx context.Context, initialEnded uint64) {
for {
elected, _, changed, ended := l.state()
if !elected || ended != initialEnded {
return
}
select {
case <-ctx.Done():
return
case <-l.Ctx.Done():
return
case <-changed:
}
}
}
func (l *Lease) state() (bool, bool, <-chan struct{}, uint64) {
l.stateMu.Lock()
defer l.stateMu.Unlock()
return l.Elected.Load(), l.running, l.changed, l.ended
}
func (l *Lease) signalStateLocked() {
close(l.changed)
l.changed = make(chan struct{})
}
// ServiceName gets lease name and id for the service.
func ServiceName(service *v1.Service) (string, string) {
name, exists := service.Annotations[kubevip.ServiceLease]
if !exists || name == "" {
name = fmt.Sprintf("kubevip-%s", service.Name)
return ServiceNameFor(service.Namespace, service.Name, service.Annotations[kubevip.ServiceLease])
}
func ServiceNameFor(namespace, serviceName, leaseName string) (string, string) {
name := leaseName
if name == "" {
name = fmt.Sprintf("kubevip-%s", serviceName)
}
serviceLeaseParts := strings.Split(name, "/")
namespace := service.Namespace
if len(serviceLeaseParts) > 1 {
namespace = serviceLeaseParts[0]

View File

@@ -34,6 +34,391 @@ func getSvcData(svc *v1.Service) (context.Context, ID) {
const serviceLeaseAnnotation = kubevip.ServiceLease
func TestServiceNameForMatchesServiceName(t *testing.T) {
for _, test := range []struct {
name string
namespace string
service string
lease string
wantNamespace string
wantName string
}{
{name: "default lease", namespace: "default", service: "api", wantNamespace: "default", wantName: "kubevip-api"},
{name: "named lease", namespace: "default", service: "api", lease: "shared", wantNamespace: "default", wantName: "shared"},
{name: "cross-namespace lease", namespace: "default", service: "api", lease: "leases/shared", wantNamespace: "leases", wantName: "shared"},
} {
t.Run(test.name, func(t *testing.T) {
service := createTestService(test.service, test.namespace, map[string]string{kubevip.ServiceLease: test.lease})
for name, serviceName := range map[string]func() (string, string){
"ServiceName": func() (string, string) { return ServiceName(service) },
"ServiceNameFor": func() (string, string) { return ServiceNameFor(test.namespace, test.service, test.lease) },
} {
namespace, leaseName := serviceName()
if namespace != test.wantNamespace || leaseName != test.wantName {
t.Errorf("%s() = %s/%s, want %s/%s", name, namespace, leaseName, test.wantNamespace, test.wantName)
}
}
})
}
}
func electLease(t *testing.T, lease *Lease) {
t.Helper()
if !lease.BeginElection() {
t.Fatal("expected lease to admit an election candidate")
}
lease.ElectionStarted()
}
func TestManagerAcquireRegistersMembership(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
id := getSvcID(service)
objectName := ServiceNamespacedName(service)
lease, first := manager.Acquire(context.Background(), id, objectName)
if !first {
t.Fatal("first acquire did not register the service")
}
if _, second := manager.Acquire(context.Background(), id, objectName); second {
t.Fatal("second acquire registered the same service twice")
}
manager.Delete(id, objectName, lease)
if manager.Get(id) != nil {
t.Fatal("lease remained after its only acquired member was deleted")
}
}
func TestElectionContextCancellationDoesNotCancelSharedLease(t *testing.T) {
manager := NewManager()
id := NewID("kubernetes", "default", "shared")
sharedLease, _ := manager.Acquire(context.Background(), id, "control-plane")
if claimed, _ := manager.Claim(id, "service"); claimed != sharedLease {
t.Fatal("second member did not join the shared lease")
}
electionCtx, cancelElection := sharedLease.NewElectionContext(context.Background())
cancelElection()
select {
case <-electionCtx.Done():
case <-time.After(time.Second):
t.Fatal("election context was not cancelled")
}
if sharedLease.Ctx.Err() != nil || manager.Get(id) != sharedLease {
t.Fatal("cancelling one election runner cancelled the shared lease")
}
memberCtx, cancelMember := sharedLease.NewElectionContext(context.Background())
defer cancelMember()
if manager.Delete(id, "control-plane", sharedLease) {
t.Fatal("deleting one member retired a shared lease")
}
if !manager.Delete(id, "service", sharedLease) {
t.Fatal("deleting the final member did not retire the lease")
}
select {
case <-memberCtx.Done():
case <-time.After(time.Second):
t.Fatal("retiring the shared lease did not cancel an election context")
}
}
func TestWaitForElectionEndObservesRapidRestart(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
serviceLease := manager.Add(context.Background(), getSvcID(service))
if !serviceLease.BeginElection() {
t.Fatal("first election did not start")
}
serviceLease.ElectionStarted()
initialEnded, elected := serviceLease.WaitForLeaderGeneration(context.Background())
if !elected {
t.Fatal("waiter did not observe the elected lease")
}
done := make(chan struct{})
go func() {
serviceLease.WaitForElectionEndAfter(context.Background(), initialEnded)
close(done)
}()
serviceLease.ElectionStopped()
if !serviceLease.BeginElection() {
t.Fatal("replacement election did not start")
}
serviceLease.ElectionStarted()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("waiter missed election end during rapid restart")
}
}
func TestManagerClaimDoesNotCreateRetiredLease(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
if lease, joined := manager.Claim(getSvcID(service), ServiceNamespacedName(service)); lease != nil || joined {
t.Fatal("claim created or joined a lease that does not exist")
}
}
func TestLeaseElectionStateCoordinatesCandidates(t *testing.T) {
leaseCtx, cancel := context.WithCancel(context.Background())
defer cancel()
lease := newLease(leaseCtx, cancel)
if !lease.BeginElection() {
t.Fatal("first election candidate was not admitted")
}
if lease.BeginElection() {
t.Fatal("second election candidate was admitted while election was running")
}
joined := make(chan bool, 1)
go func() {
joined <- lease.WaitForLeader(context.Background())
}()
lease.ElectionStarted()
select {
case elected := <-joined:
if !elected {
t.Fatal("follower did not observe elected lease")
}
case <-time.After(time.Second):
t.Fatal("follower remained blocked after election succeeded")
}
lease.ElectionStopped()
if !lease.BeginElection() {
t.Fatal("lease did not admit a new candidate after election stopped")
}
}
func TestLeaseWaitForLeaderReturnsWhenCandidateStops(t *testing.T) {
leaseCtx, cancel := context.WithCancel(context.Background())
defer cancel()
lease := newLease(leaseCtx, cancel)
if !lease.BeginElection() {
t.Fatal("candidate was not admitted")
}
joined := make(chan bool, 1)
go func() {
joined <- lease.WaitForLeader(context.Background())
}()
lease.ElectionStopped()
select {
case elected := <-joined:
if elected {
t.Fatal("follower observed a leader after candidate stopped")
}
case <-time.After(time.Second):
t.Fatal("follower remained blocked after candidate stopped")
}
}
// TestLeaseSupportsRetakingElectionAfterCandidateStops exercises the retry
// StartCluster relies on for a shared control-plane/Services lease: once
// WaitForLeader reports the campaign ended without ever electing a leader, a
// waiter must be able to begin its own election immediately instead of being
// left with no active runner.
func TestLeaseSupportsRetakingElectionAfterCandidateStops(t *testing.T) {
leaseCtx, cancel := context.WithCancel(context.Background())
defer cancel()
lease := newLease(leaseCtx, cancel)
if !lease.BeginElection() {
t.Fatal("candidate was not admitted")
}
retried := make(chan bool, 1)
go func() {
if lease.WaitForLeader(context.Background()) {
retried <- false
return
}
retried <- lease.BeginElection()
}()
lease.ElectionStopped()
select {
case tookOver := <-retried:
if !tookOver {
t.Fatal("waiter could not begin its own election after the shared campaign ended without a leader")
}
case <-time.After(time.Second):
t.Fatal("waiter remained blocked after candidate stopped")
}
}
func TestLeaseWaitForElectionEndReleasesFollowers(t *testing.T) {
leaseCtx, cancel := context.WithCancel(context.Background())
defer cancel()
lease := newLease(leaseCtx, cancel)
if !lease.BeginElection() {
t.Fatal("candidate was not admitted")
}
lease.ElectionStarted()
finished := make(chan struct{})
go func() {
lease.WaitForElectionEnd(context.Background())
close(finished)
}()
lease.ElectionStopped()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("follower remained blocked after leadership stopped")
}
}
func TestLeaseWaitForLeaderReturnsWhenContextCancelled(t *testing.T) {
for _, cancelWait := range []struct {
name string
cancel func(context.CancelFunc, context.CancelFunc)
}{
{"caller context", func(cancelCaller, _ context.CancelFunc) { cancelCaller() }},
{"lease context", func(_, cancelLease context.CancelFunc) { cancelLease() }},
} {
t.Run(cancelWait.name, func(t *testing.T) {
leaseCtx, cancelLease := context.WithCancel(context.Background())
defer cancelLease()
lease := newLease(leaseCtx, cancelLease)
if !lease.BeginElection() {
t.Fatal("candidate was not admitted")
}
callerCtx, cancelCaller := context.WithCancel(context.Background())
defer cancelCaller()
result := make(chan bool, 1)
go func() {
result <- lease.WaitForLeader(callerCtx)
}()
cancelWait.cancel(cancelCaller, cancelLease)
select {
case elected := <-result:
if elected {
t.Fatal("waiter observed a leader after cancellation")
}
case <-time.After(time.Second):
t.Fatal("waiter remained blocked after cancellation")
}
})
}
}
func TestLeaseWaitForElectionEndReturnsWhenContextCancelled(t *testing.T) {
for _, cancelWait := range []struct {
name string
cancel func(context.CancelFunc, context.CancelFunc)
}{
{"caller context", func(cancelCaller, _ context.CancelFunc) { cancelCaller() }},
{"lease context", func(_, cancelLease context.CancelFunc) { cancelLease() }},
} {
t.Run(cancelWait.name, func(t *testing.T) {
leaseCtx, cancelLease := context.WithCancel(context.Background())
defer cancelLease()
lease := newLease(leaseCtx, cancelLease)
electLease(t, lease)
callerCtx, cancelCaller := context.WithCancel(context.Background())
defer cancelCaller()
finished := make(chan struct{})
go func() {
lease.WaitForElectionEnd(callerCtx)
close(finished)
}()
cancelWait.cancel(cancelCaller, cancelLease)
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("waiter remained blocked after cancellation")
}
})
}
}
func TestManagerAcquireRegistersConcurrentMemberOnce(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
id := getSvcID(service)
objectName := ServiceNamespacedName(service)
type result struct {
lease *Lease
isNew bool
}
results := make(chan result, 64)
var wg sync.WaitGroup
for range cap(results) {
wg.Go(func() {
lease, isNew := manager.Acquire(context.Background(), id, objectName)
results <- result{lease, isNew}
})
}
wg.Wait()
close(results)
var lease *Lease
newMembers := 0
for result := range results {
if lease == nil {
lease = result.lease
} else if result.lease != lease {
t.Fatal("concurrent acquires returned different leases")
}
if result.isNew {
newMembers++
}
}
if newMembers != 1 {
t.Fatalf("new member registrations = %d, want 1", newMembers)
}
manager.Delete(id, objectName, lease)
}
func TestManagerClaimRegistersConcurrentMemberOnce(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
id := getSvcID(service)
objectName := ServiceNamespacedName(service)
lease := manager.Add(context.Background(), id)
type result struct {
lease *Lease
isNew bool
}
results := make(chan result, 64)
var wg sync.WaitGroup
for range cap(results) {
wg.Go(func() {
claimed, isNew := manager.Claim(id, objectName)
results <- result{claimed, isNew}
})
}
wg.Wait()
close(results)
newMembers := 0
for result := range results {
if result.lease != lease {
t.Fatal("concurrent claims returned a different lease")
}
if result.isNew {
newMembers++
}
}
if newMembers != 1 {
t.Fatalf("new member registrations = %d, want 1", newMembers)
}
manager.Delete(id, objectName, lease)
}
// TestManager_Add_NewLease tests adding a new service with a new lease
func TestManager_Add_NewLease(t *testing.T) {
mgr := NewManager()
@@ -54,10 +439,6 @@ func TestManager_Add_NewLease(t *testing.T) {
if leaseID.Cancel == nil {
t.Error("expected lease cancel func to be non-nil")
}
if leaseID.Started == nil {
t.Error("expected lease Started channel to be non-nil")
}
}
// TestManager_Add_ExistingLease tests adding a service with an existing lease
@@ -254,33 +635,6 @@ func TestManager_ConcurrentAccess(t *testing.T) {
}
}
// TestLease_StartedChannel tests the Started channel behavior
func TestLease_StartedChannel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
lease := newLease(ctx, cancel)
// Started channel should be open initially
select {
case <-lease.Started:
t.Fatal("expected Started channel to be open initially")
default:
// Expected
}
// Close the channel
close(lease.Started)
// Now it should be closed
select {
case <-lease.Started:
// Expected
default:
t.Error("expected Started channel to be closed after close()")
}
}
// TestGetName_WithoutAnnotation tests with no annotation
func TestGetName_WithoutAnnotation(t *testing.T) {
svc := createTestService("my-service", "my-namespace", nil)
@@ -423,8 +777,8 @@ func TestManager_LeaderElectionRestartScenario_etcd(t *testing.T) {
t.Fatal("expected first add to return isNew=true")
}
// Simulate leadership acquired - close Started channel
close(lease1.Started)
// Simulate leadership acquired.
electLease(t, lease1)
// Simulate leadership lost - the leader election function should delete the lease
// This is the fix: delete the lease when RunOrDie returns
@@ -438,22 +792,17 @@ func TestManager_LeaderElectionRestartScenario_etcd(t *testing.T) {
// Simulate restartable service watcher calling StartServicesLeaderElection again
ctx2, leaseID2 := getSvcData(svc)
lease2 := mgr.Add(ctx2, leaseID2)
isNew2 := lease1.Add(objectName1)
isNew2 := lease2.Add(objectName1)
if !isNew2 {
t.Fatal("expected second add after delete to return isNew=true")
}
// Verify we got a new lease with a fresh Started channel
// Verify we got a new lease with no elected leader.
if lease1 == lease2 {
t.Error("expected new lease to be different from old lease")
}
// Verify the new Started channel is not closed
select {
case <-lease2.Started:
t.Error("expected new lease's Started channel to be open")
default:
// Expected
if lease2.Elected.Load() {
t.Error("expected new lease to have no elected leader")
}
}
@@ -479,8 +828,11 @@ func TestManager_CommonLeaseScenario(t *testing.T) {
t.Error("expected first add to return isNew=true")
}
// Simulate first service starting leadership
close(lease1.Started)
// Simulate first service starting leadership.
if !lease1.BeginElection() {
t.Fatal("expected first service to become election candidate")
}
lease1.ElectionStarted()
objectName2 := ServiceNamespacedName(svc2)
@@ -495,6 +847,9 @@ func TestManager_CommonLeaseScenario(t *testing.T) {
if lease1 != lease2 {
t.Error("expected same lease for services with same lease annotation")
}
if !lease2.WaitForLeader(context.Background()) {
t.Fatal("shared-lease follower did not observe the elected lease")
}
// Delete first service - lease should still exist
mgr.Delete(leaseID1, objectName1, nil)
@@ -526,8 +881,8 @@ func TestManager_RaceCondition_LeaseExistsBeforeDelete(t *testing.T) {
t.Fatal("expected first add to return isNew=true")
}
// Simulate leadership acquired - close Started channel
close(lease1.Started)
// Simulate leadership acquired.
electLease(t, lease1)
// Simulate a second goroutine calling Add BEFORE the first goroutine's defer deletes the lease
// This is the race condition scenario
@@ -542,12 +897,8 @@ func TestManager_RaceCondition_LeaseExistsBeforeDelete(t *testing.T) {
t.Error("expected same lease to be returned")
}
// The Started channel should be closed (from the first run)
select {
case <-lease2.Started:
// Expected - channel is closed
default:
t.Error("expected Started channel to be closed")
if !lease2.Elected.Load() {
t.Error("expected lease to remain elected")
}
// Now the first goroutine's defer deletes the lease
@@ -581,8 +932,8 @@ func TestManager_NonCommonLease_MultipleAdds(t *testing.T) {
t.Error("expected first add to return isNew=true")
}
// Close Started to simulate leadership acquired
close(lease1.Started)
// Simulate leadership acquired.
electLease(t, lease1)
// Second Add (simulating another goroutine or restart attempt)
ctx2, leaseID2 := getSvcData(svc)
@@ -649,12 +1000,8 @@ func TestManager_LeaseContextCancelledBeforeStarted(t *testing.T) {
t.Error("expected second add to return isNew=false")
}
// Verify Started is not closed yet
select {
case <-lease2.Started:
t.Error("expected Started channel to be open")
default:
// Expected
if lease2.Elected.Load() {
t.Error("expected lease to have no elected leader")
}
// Cancel the lease context (simulating timeout or leadership loss before acquiring)
@@ -710,7 +1057,7 @@ func TestManager_RestartAfterLeaseContextCancelled(t *testing.T) {
t.Error("expected new lease after delete")
}
// Verify new lease has fresh context and Started channel
// Verify new lease has a fresh active context and no elected leader.
select {
case <-lease2.Ctx.Done():
t.Error("expected new lease context to be active")
@@ -718,11 +1065,8 @@ func TestManager_RestartAfterLeaseContextCancelled(t *testing.T) {
// Expected
}
select {
case <-lease2.Started:
t.Error("expected new lease Started channel to be open")
default:
// Expected
if lease2.Elected.Load() {
t.Error("expected new lease to have no elected leader")
}
}
@@ -745,8 +1089,8 @@ func TestManager_NonCommonLease_WaitForLeaseContextDone(t *testing.T) {
t.Fatal("expected first add to return isNew=true")
}
// Simulate leadership acquired
close(lease1.Started)
// Simulate leadership acquired.
electLease(t, lease1)
// Second Add - simulates another goroutine trying to start leader election
// This should return isNew=false
@@ -762,12 +1106,8 @@ func TestManager_NonCommonLease_WaitForLeaseContextDone(t *testing.T) {
t.Error("expected same lease to be returned")
}
// Verify Started channel is closed (leadership was acquired by first)
select {
case <-lease2.Started:
// Expected - channel is closed
default:
t.Error("expected Started channel to be closed")
if !lease2.Elected.Load() {
t.Error("expected lease to remain elected")
}
// In the actual code (leader.go), when isNew=false for non-common lease,
@@ -834,7 +1174,7 @@ func TestManager_NonCommonLease_SpinLoopPrevention(t *testing.T) {
t.Fatal("expected first add to return isNew=true")
}
close(lease1.Started)
electLease(t, lease1)
// Track how many times Add is called in a tight loop
// In the buggy code, this would spin forever
@@ -898,7 +1238,7 @@ func TestManager_NonCommonLease_ServiceContextCancellation(t *testing.T) {
ctx1, leaseID1 := getSvcData(svc)
lease1 := mgr.Add(ctx1, leaseID1)
_ = lease1.Add(objectName1)
close(lease1.Started)
electLease(t, lease1)
// Second Add - returns isNew=false
ctx2, leaseID2 := getSvcData(svc)
@@ -978,6 +1318,37 @@ func TestManager_Delete_DoesNotCancelRecreatedLease(t *testing.T) {
}
}
func TestManagerDeleteDoesNotCancelReplacementAfterDirectLeaseCancellation(t *testing.T) {
manager := NewManager()
service := createTestService("service", "default", nil)
id := getSvcID(service)
objectName := ServiceNamespacedName(service)
old, isNew := manager.Acquire(context.Background(), id, objectName)
if !isNew {
t.Fatal("initial acquire did not register the service")
}
old.Cancel()
fresh, isNew := manager.Acquire(context.Background(), id, objectName)
if !isNew {
t.Fatal("replacement acquire did not register the service")
}
if fresh == old {
t.Fatal("acquire reused a directly cancelled lease")
}
manager.Delete(id, objectName, old)
if fresh.Ctx.Err() != nil {
t.Fatal("late cleanup for a directly cancelled lease cancelled its replacement")
}
if manager.Get(id) != fresh {
t.Fatal("late cleanup for a directly cancelled lease removed its replacement")
}
manager.Delete(id, objectName, fresh)
}
// TestManager_Add_AfterCancelWithoutDelete_ReusesDoomedLease reproduces the
// second half of the service rebuild race.
//

View File

@@ -17,6 +17,7 @@ import (
"github.com/kube-vip/kube-vip/pkg/node"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/services"
"github.com/kube-vip/kube-vip/pkg/vip"
"k8s.io/client-go/kubernetes"
)
@@ -96,7 +97,15 @@ func (c *Common) GlobalLeader(ctx context.Context, leaseName string) {
})
}
c.runGlobalElection(servicesCtx, c, leaseName, c.config, c.electionMgr)
var vips []string
if c.svcProcessor != nil {
var err error
vips, err = c.svcProcessor.ElectionVIPs(servicesCtx)
if err != nil {
log.Warn("unable to list Service VIPs for Lease metadata", "err", err)
}
}
c.runGlobalElection(servicesCtx, c, leaseName, c.config, c.electionMgr, vips)
}
func (c *Common) ServicesNoLeader(ctx context.Context) error {
@@ -155,7 +164,7 @@ func (c *Common) OnNewLeader(identity string) {
}
func (c *Common) runGlobalElection(ctx context.Context, a election.Actions, leaseName string,
config *kubevip.Config, electionManager *election.Manager) {
config *kubevip.Config, electionManager *election.Manager, vips []string) {
log.Debug("starting global election")
ns, leaseName := lease.NamespaceName(leaseName, config)
@@ -163,99 +172,73 @@ func (c *Common) runGlobalElection(ctx context.Context, a election.Actions, leas
leaseID := lease.NewID(config.LeaderElectionType, ns, leaseName)
objectName := lease.ObjectName(leaseID, "svcs0")
// objLease, isNew, isSharedLease := c.leaseMgr.Add(leaseID, objectName)
objLease, _ := c.leaseMgr.Acquire(context.Background(), leaseID, objectName)
defer c.leaseMgr.Delete(leaseID, objectName, objLease)
electionCtx, cancelElection := objLease.NewElectionContext(ctx)
defer cancelElection()
objLease := c.leaseMgr.Add(ctx, leaseID)
isNew := objLease.Add(objectName)
// this service was already processed so we do not need to do anything
if !isNew {
log.Debug("this election was already done, waiting for it to finish", "lease", c.config.ServicesLeaseName)
// Wait for either the service context or lease context to be done
select {
case <-ctx.Done():
// Service was deleted
c.leaseMgr.Delete(leaseID, objectName, objLease)
case <-objLease.Ctx.Done():
// Leader election ended (leadership lost or context cancelled)
}
return
}
objLease.Lock()
defer func() {
objLease.Unlock()
}()
if objLease.Elected.Load() {
objLease.Unlock()
for !objLease.BeginElection() {
log.Debug("this election was already done, shared lease", "lease", leaseID.Name())
leaderGeneration, elected := objLease.WaitForLeaderGeneration(electionCtx)
if !elected {
if electionCtx.Err() != nil {
return
}
continue
}
// wait for leader election to start or context to be done
select {
case <-objLease.Started:
case <-objLease.Ctx.Done():
// Lease was cancelled (e.g., leader election ended), return immediately
// This allows the restart loop to create a fresh lease
log.Debug("lease context cancelled before leader election started", "lease", leaseID.Name())
leaderCtx, cancelLeader := context.WithCancel(electionCtx)
wg := sync.WaitGroup{}
wg.Go(func() {
a.OnStartedLeading(leaderCtx)
})
objLease.WaitForElectionEndAfter(electionCtx, leaderGeneration)
cancelLeader()
wg.Wait()
if electionCtx.Err() != nil {
return
}
a.OnStartedLeading(objLease.Ctx)
log.Debug("waiting for lease to finish", "lease", leaseID.Name())
// wait for leaderelection to be finished
<-objLease.Ctx.Done()
// we can do cleanup here
a.OnStoppedLeading()
log.Error("lost leadership, restarting kube-vip", "lease", leaseID.Name())
c.killFunc()
return
}
// For new leases (not shared), ensure cleanup when the leader election ends
// This is critical for the restartable service watcher to be able to restart
// the leader election after leadership loss
defer func() {
// Delete the lease from the manager so subsequent calls can create a fresh lease
// This handles the case where leader election ends due to:
// 1. Leadership loss (e.g., network timeout)
// 2. Context cancellation
// 3. Any other reason RunOrDie returns
c.leaseMgr.Delete(leaseID, objectName, objLease)
}()
wg := sync.WaitGroup{}
defer objLease.ElectionStopped()
defer wg.Wait()
run := &election.RunConfig{
Config: config,
LeaseID: leaseID,
LeaseAnnotations: map[string]string{},
VIPs: vips,
Mgr: electionManager,
OnStartedLeading: func(ctx context.Context) {
objLease.ElectionStarted()
wg.Go(func() {
objLease.Elected.Store(true)
objLease.Unlock()
close(objLease.Started)
a.OnStartedLeading(ctx)
metrics.LeaderTransitionsTotal.WithLabelValues(leaseID.Name()).Inc()
metrics.IsLeader.WithLabelValues(config.NodeName, leaseID.Name()).Set(1)
})
},
OnStoppedLeading: func() {
objLease.Elected.Store(false)
objLease.ElectionStopped()
a.OnStoppedLeading()
metrics.IsLeader.WithLabelValues(config.NodeName, leaseID.Name()).Set(0)
},
OnNewLeader: a.OnNewLeader,
}
if err := election.RunOrDie(ctx, run, config); err != nil {
if err := election.RunOrDie(electionCtx, run, config); err != nil {
log.Error("leaderelection failed", "err", err, "id", config.NodeName, "name", leaseID.Name())
}
}
func controlPlaneElectionVIPs(config *kubevip.Config) []string {
configured := config.VIP
if config.Address != "" {
configured = config.Address
}
return vip.Split(configured)
}

View File

@@ -0,0 +1,115 @@
package worker
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
)
type sharedElectionActions struct {
started chan struct{}
stopped chan struct{}
}
func (a *sharedElectionActions) OnStartedLeading(ctx context.Context) {
close(a.started)
<-ctx.Done()
}
func (a *sharedElectionActions) OnStoppedLeading() {
close(a.stopped)
}
func (a *sharedElectionActions) OnNewLeader(string) {}
func TestGlobalElectionFollowsSharedLeaseLeadership(t *testing.T) {
config := &kubevip.Config{KubernetesLeaderElection: kubevip.KubernetesLeaderElection{LeaseName: "default/shared"}}
leaseID := lease.NewID(config.LeaderElectionType, "default", "shared")
leaseMgr := lease.NewManager()
sharedLease, _ := leaseMgr.Acquire(context.Background(), leaseID, "service")
if !sharedLease.BeginElection() {
t.Fatal("Service election did not start")
}
sharedLease.ElectionStarted()
actions := &sharedElectionActions{started: make(chan struct{}), stopped: make(chan struct{})}
var killed atomic.Bool
common := &Common{config: config, leaseMgr: leaseMgr, killFunc: func() { killed.Store(true) }}
done := make(chan struct{})
go func() {
common.runGlobalElection(context.Background(), actions, config.LeaseName, config, nil, nil)
close(done)
}()
select {
case <-actions.started:
case <-time.After(time.Second):
t.Fatal("global election follower did not activate")
}
sharedLease.ElectionStopped()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("global election follower did not stop after leadership ended")
}
select {
case <-actions.stopped:
default:
t.Fatal("global election follower did not run leadership cleanup")
}
if !killed.Load() {
t.Fatal("global election follower did not request restart after leadership loss")
}
if sharedLease.Ctx.Err() != nil || leaseMgr.Get(leaseID) != sharedLease {
t.Fatal("global election follower cancelled the surviving Service lease")
}
leaseMgr.Delete(leaseID, "service", sharedLease)
}
func TestGlobalElectionFollowerShutdownIsNotLeadershipLoss(t *testing.T) {
config := &kubevip.Config{KubernetesLeaderElection: kubevip.KubernetesLeaderElection{LeaseName: "default/shared"}}
leaseID := lease.NewID(config.LeaderElectionType, "default", "shared")
leaseMgr := lease.NewManager()
sharedLease, _ := leaseMgr.Acquire(context.Background(), leaseID, "service")
if !sharedLease.BeginElection() {
t.Fatal("Service election did not start")
}
sharedLease.ElectionStarted()
actions := &sharedElectionActions{started: make(chan struct{}), stopped: make(chan struct{})}
var killed atomic.Bool
common := &Common{config: config, leaseMgr: leaseMgr, killFunc: func() { killed.Store(true) }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
common.runGlobalElection(ctx, actions, config.LeaseName, config, nil, nil)
close(done)
}()
select {
case <-actions.started:
case <-time.After(time.Second):
t.Fatal("global election follower did not activate")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("global election follower did not stop with its parent context")
}
select {
case <-actions.stopped:
t.Fatal("graceful follower shutdown was reported as leadership loss")
default:
}
if killed.Load() {
t.Fatal("graceful follower shutdown requested a process restart")
}
if sharedLease.Ctx.Err() != nil || leaseMgr.Get(leaseID) != sharedLease {
t.Fatal("global follower shutdown cancelled the surviving Service lease")
}
leaseMgr.Delete(leaseID, "service", sharedLease)
}

View File

@@ -27,6 +27,8 @@ import (
"k8s.io/client-go/kubernetes"
)
const controlPlaneTunnelOwner = "control-plane"
type WireGuard struct {
Common
tunnelMgr *wireguard.TunnelManager
@@ -94,7 +96,7 @@ func (w *WireGuard) StartControlPlane(ctx context.Context, electionManager *elec
log.Error("no WireGuard tunnel configuration found for control plane VIP", "vip", w.config.VIP)
return
}
w.runGlobalElection(ctx, w, w.config.LeaseName, w.config, electionManager)
w.runGlobalElection(ctx, w, w.config.LeaseName, w.config, electionManager, controlPlaneElectionVIPs(w.config))
}
func (w *WireGuard) ConfigureServices() {
@@ -121,10 +123,9 @@ func (w *WireGuard) Name() string {
func (w *WireGuard) OnStartedLeading(ctx context.Context) {
// Bring up the WireGuard tunnel for control plane VIP
err := w.tunnelMgr.BringUpTunnelForVIP(w.config.VIP)
err := w.tunnelMgr.AcquireTunnelForVIP(w.config.VIP, controlPlaneTunnelOwner)
if err != nil {
log.Error("could not start wireguard tunnel for control plane", "vip", w.config.VIP, "err", err)
_ = w.tunnelMgr.TearDownTunnelForVIP(w.config.VIP)
w.killFunc()
return
}
@@ -133,6 +134,7 @@ func (w *WireGuard) OnStartedLeading(ctx context.Context) {
wg := w.tunnelMgr.GetTunnelForVIP(w.config.VIP)
if wg == nil {
log.Error("failed to get wireguard tunnel after bringing up", "vip", w.config.VIP)
_ = w.tunnelMgr.ReleaseTunnelForVIP(w.config.VIP, controlPlaneTunnelOwner)
w.killFunc()
return
}
@@ -140,7 +142,7 @@ func (w *WireGuard) OnStartedLeading(ctx context.Context) {
tunnelConfig := w.tunnelMgr.GetConfigForVIP(w.config.VIP)
if tunnelConfig == nil {
log.Error("failed to get tunnel configuration", "vip", w.config.VIP)
_ = w.tunnelMgr.TearDownTunnelForVIP(w.config.VIP)
_ = w.tunnelMgr.ReleaseTunnelForVIP(w.config.VIP, controlPlaneTunnelOwner)
w.killFunc()
return
}
@@ -277,6 +279,6 @@ func (w *WireGuard) OnNewLeader(identity string) {
return
}
// safety check - tear down tunnel if we're not the leader
_ = w.tunnelMgr.TearDownTunnelForVIP(w.config.VIP)
_ = w.tunnelMgr.ReleaseTunnelForVIP(w.config.VIP, controlPlaneTunnelOwner)
log.Info("new leader elected", "id", identity)
}

View File

@@ -3,109 +3,178 @@ package servicecontext
import (
"context"
"sync"
"sync/atomic"
)
type Context struct {
Ctx context.Context
Cancel context.CancelFunc
IsWatched bool
ConfiguredNetworks sync.Map
EndpointsReady chan any
mu sync.Mutex
epReady sync.Once
leaderElection sync.Once
Signalled atomic.Bool
LeaderCancel context.CancelFunc
Ctx context.Context
Cancel context.CancelFunc
ConfiguredNetworks sync.Map
stateMutex sync.Mutex
ready bool
isWatched bool
watchingStopped chan struct{}
endpointsReady chan any
endpointsLost chan any
readinessGeneration uint64
readinessOperations int
readinessChanged *sync.Cond
}
func New(ctx context.Context) *Context {
// context and cancel stored for a future use, gosec linter disabled
svcCtx, svcCancel := context.WithCancel(ctx) //nolint:gosec
return &Context{
Ctx: svcCtx,
Cancel: svcCancel,
EndpointsReady: make(chan any),
serviceContext := &Context{
Ctx: svcCtx,
Cancel: svcCancel,
endpointsReady: make(chan any),
endpointsLost: make(chan any),
readinessGeneration: 1,
}
serviceContext.readinessChanged = sync.NewCond(&serviceContext.stateMutex)
return serviceContext
}
func (ctx *Context) HasConfiguredNetworks() bool {
cnt := 0
ctx.ConfiguredNetworks.Range(func(_ any, _ any) bool {
cnt++
return cnt < 1
})
return cnt > 0
// ReadinessState returns one readiness lifecycle. The ready channel is closed
// when endpoints become usable and the lost channel is closed when that exact
// generation is reset.
func (ctx *Context) ReadinessState() (uint64, <-chan any, <-chan any, bool) {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
return ctx.readinessGeneration, ctx.endpointsReady, ctx.endpointsLost, ctx.ready
}
func (ctx *Context) IsNetworkConfigured(ip string) bool {
_, exists := ctx.ConfiguredNetworks.Load(ip)
return exists
func (ctx *Context) IsReady() bool {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
return ctx.ready
}
// StartLeaderElectionOnce runs f only on its first call for this service context.
// The leader-election loop restarts itself internally until the context is
// cancelled, so it must be started exactly once per service lifetime. Unlike
// readiness, this is never reset: the loop outlives individual endpoint events.
func (ctx *Context) StartLeaderElectionOnce(f func()) {
ctx.leaderElection.Do(f)
// ReadinessGenerationCurrent reports whether generation is the current usable
// endpoint generation for this Service context.
func (ctx *Context) ReadinessGenerationCurrent(generation uint64) bool {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
return ctx.ready && ctx.readinessGeneration == generation
}
func (ctx *Context) ResetReadinessGeneration(generation uint64) bool {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if !ctx.ready || ctx.readinessGeneration != generation {
return false
}
close(ctx.endpointsLost)
ctx.readinessGeneration++
ctx.endpointsReady = make(chan any)
ctx.endpointsLost = make(chan any)
ctx.ready = false
for ctx.readinessOperations > 0 {
ctx.readinessChanged.Wait()
}
return true
}
func (ctx *Context) SignalReadiness() {
ctx.mu.Lock()
defer ctx.mu.Unlock()
ctx.epReady.Do(func() {
close(ctx.EndpointsReady)
ctx.Signalled.Store(true)
})
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if ctx.ready {
return
}
close(ctx.endpointsReady)
ctx.ready = true
}
func (ctx *Context) ResetReadiness() {
ctx.mu.Lock()
defer ctx.mu.Unlock()
if ctx.Signalled.Load() {
ctx.EndpointsReady = make(chan any)
ctx.epReady = sync.Once{}
ctx.Signalled.Store(false)
// WaitForReadiness reserves the first ready generation that remains current.
// The caller must release the returned reservation after its datapath operation.
func (ctx *Context) WaitForReadiness() (func(), bool) {
for {
generation, ready, _, isReady := ctx.ReadinessState()
if !isReady {
select {
case <-ctx.Ctx.Done():
return nil, false
case <-ready:
}
}
if release, acquired := ctx.AcquireReadinessGeneration(generation); acquired {
return release, true
}
}
}
func (ctx *Context) GetEndpointsReady() chan any {
ctx.mu.Lock()
defer ctx.mu.Unlock()
// AcquireReadinessGeneration reserves a ready generation while a caller starts
// or stops datapath work. ResetReadinessGeneration waits for the returned
// release function, preventing that work from outliving its endpoint state.
func (ctx *Context) AcquireReadinessGeneration(generation uint64) (func(), bool) {
if !ctx.acquireReadinessGeneration(generation) {
return nil, false
}
return ctx.EndpointsReady
var releaseOnce sync.Once
return func() {
releaseOnce.Do(ctx.releaseReadinessGeneration)
}, true
}
func (ctx *Context) SetLeaderCancel(cancel context.CancelFunc) {
ctx.mu.Lock()
defer ctx.mu.Unlock()
ctx.LeaderCancel = cancel
func (ctx *Context) acquireReadinessGeneration(generation uint64) bool {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if ctx.Ctx.Err() != nil || !ctx.ready || ctx.readinessGeneration != generation {
return false
}
ctx.readinessOperations++
return true
}
func (ctx *Context) CallLeaderCancel() {
ctx.mu.Lock()
cancel := ctx.LeaderCancel
ctx.mu.Unlock()
if cancel != nil {
cancel()
func (ctx *Context) releaseReadinessGeneration() {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
ctx.readinessOperations--
if ctx.readinessOperations == 0 {
ctx.readinessChanged.Broadcast()
}
}
func (ctx *Context) SetWatched(watched bool) {
ctx.mu.Lock()
defer ctx.mu.Unlock()
ctx.IsWatched = watched
func (ctx *Context) StartWatching() bool {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if ctx.Ctx.Err() != nil || ctx.isWatched {
return false
}
ctx.isWatched = true
ctx.watchingStopped = make(chan struct{})
return true
}
func (ctx *Context) IsWatchedLocked() bool {
ctx.mu.Lock()
defer ctx.mu.Unlock()
return ctx.IsWatched
func (ctx *Context) StopWatching() {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if !ctx.isWatched {
return
}
ctx.isWatched = false
close(ctx.watchingStopped)
}
func (ctx *Context) WaitForWatchingStopped(waitCtx context.Context) error {
stopped := ctx.watchingStoppedSignal()
if stopped == nil {
return nil
}
select {
case <-waitCtx.Done():
return waitCtx.Err()
case <-stopped:
return nil
}
}
func (ctx *Context) watchingStoppedSignal() <-chan struct{} {
ctx.stateMutex.Lock()
defer ctx.stateMutex.Unlock()
if !ctx.isWatched {
return nil
}
return ctx.watchingStopped
}

View File

@@ -15,13 +15,13 @@ func TestReadinessResetConcurrentWithSignal(t *testing.T) {
<-start
for range 1000 {
ctx.SignalReadiness()
ctx.ResetReadiness()
resetReadiness(ctx)
}
})
wg.Go(func() {
<-start
for range 1000 {
ready := ctx.GetEndpointsReady()
_, ready, _, _ := ctx.ReadinessState()
select {
case <-ready:
default:
@@ -32,25 +32,3 @@ func TestReadinessResetConcurrentWithSignal(t *testing.T) {
close(start)
wg.Wait()
}
func TestLeaderCancelConcurrentWithEndpointCleanup(t *testing.T) {
ctx := New(context.Background())
start := make(chan struct{})
var wg sync.WaitGroup
wg.Go(func() {
<-start
for range 1000 {
ctx.SetLeaderCancel(func() {})
}
})
wg.Go(func() {
<-start
for range 1000 {
ctx.CallLeaderCancel()
}
})
close(start)
wg.Wait()
}

View File

@@ -0,0 +1,210 @@
package servicecontext
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func resetReadiness(ctx *Context) bool {
generation, _, _, ready := ctx.ReadinessState()
return ready && ctx.ResetReadinessGeneration(generation)
}
func TestReadinessResetCreatesNewGeneration(t *testing.T) {
svcCtx := New(context.Background())
defer svcCtx.Cancel()
firstGeneration, first, firstLost, firstReady := svcCtx.ReadinessState()
if firstGeneration != 1 || firstReady {
t.Fatalf("initial readiness state = generation %d, ready %t; want generation 1, ready false", firstGeneration, firstReady)
}
svcCtx.SignalReadiness()
select {
case <-first:
default:
t.Fatal("first readiness generation was not signalled")
}
if !resetReadiness(svcCtx) {
t.Fatal("first readiness generation was not reset")
}
select {
case <-firstLost:
default:
t.Fatal("first readiness generation loss was not signalled")
}
secondGeneration, second, secondLost, secondReady := svcCtx.ReadinessState()
if secondGeneration != firstGeneration+1 || secondReady {
t.Fatalf("reset readiness state = generation %d, ready %t; want generation %d, ready false", secondGeneration, secondReady, firstGeneration+1)
}
if first == second {
t.Fatal("readiness reset reused the previous generation")
}
if firstLost == secondLost {
t.Fatal("readiness reset reused the previous loss signal")
}
select {
case <-second:
t.Fatal("new readiness generation was already signalled")
default:
}
svcCtx.SignalReadiness()
select {
case <-second:
default:
t.Fatal("second readiness generation was not signalled")
}
}
func TestParentSurvivesServiceCancellation(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
defer cancel()
svcCtx := New(parent)
svcCtx.Cancel()
if parent.Err() != nil {
t.Fatal("cancelling a service context cancelled its parent")
}
}
func TestResetReadinessGenerationWaitsForActivation(t *testing.T) {
svcCtx := New(context.Background())
defer svcCtx.Cancel()
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
release, acquired := svcCtx.WaitForReadiness()
if !acquired {
t.Fatal("ready generation was not available for activation")
}
resetComplete := make(chan bool, 1)
go func() {
resetComplete <- svcCtx.ResetReadinessGeneration(generation)
}()
deadline := time.Now().Add(time.Second)
for {
currentGeneration, _, _, _ := svcCtx.ReadinessState()
if currentGeneration != generation {
break
}
if time.Now().After(deadline) {
t.Fatal("readiness reset did not start")
}
time.Sleep(time.Millisecond)
}
select {
case <-resetComplete:
t.Fatal("readiness reset returned before the activation released its generation")
default:
}
release()
select {
case reset := <-resetComplete:
if !reset {
t.Fatal("readiness reset rejected its current generation")
}
case <-time.After(time.Second):
t.Fatal("readiness reset did not complete after activation released its generation")
}
}
func TestStartWatchingClaimsOnce(t *testing.T) {
svcCtx := New(context.Background())
defer svcCtx.Cancel()
var claims atomic.Int64
var wg sync.WaitGroup
for range 32 {
wg.Go(func() {
if svcCtx.StartWatching() {
claims.Add(1)
}
})
}
wg.Wait()
if got := claims.Load(); got != 1 {
t.Fatalf("watcher claims = %d, want 1", got)
}
svcCtx.StopWatching()
if !svcCtx.StartWatching() {
t.Fatal("watcher ownership was not released")
}
}
func TestCancelledContextCannotStartWatching(t *testing.T) {
svcCtx := New(context.Background())
svcCtx.Cancel()
if svcCtx.StartWatching() {
t.Fatal("cancelled Service context acquired watcher ownership")
}
}
func TestWaitForWatchingStopped(t *testing.T) {
svcCtx := New(context.Background())
defer svcCtx.Cancel()
if !svcCtx.StartWatching() {
t.Fatal("watcher ownership was not acquired")
}
done := make(chan error, 1)
go func() {
done <- svcCtx.WaitForWatchingStopped(context.Background())
}()
select {
case <-done:
t.Fatal("WaitForWatchingStopped returned while the watcher was active")
case <-time.After(20 * time.Millisecond):
}
svcCtx.StopWatching()
select {
case err := <-done:
if err != nil {
t.Fatalf("WaitForWatchingStopped() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("WaitForWatchingStopped did not return after watcher shutdown")
}
}
func TestConcurrentStateTransitions(t *testing.T) {
svcCtx := New(context.Background())
defer svcCtx.Cancel()
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
svcCtx.SignalReadiness()
resetReadiness(svcCtx)
})
}
wg.Wait()
if svcCtx.IsReady() && !resetReadiness(svcCtx) {
t.Fatal("final readiness generation was not reset")
}
generation, ready, lost, isReady := svcCtx.ReadinessState()
if isReady {
t.Fatal("concurrent transitions left the context ready after every signal was reset")
}
if generation == 1 {
t.Fatal("concurrent transitions did not advance the readiness generation")
}
select {
case <-ready:
t.Fatal("current readiness channel was already closed")
default:
}
select {
case <-lost:
t.Fatal("current readiness-loss channel was already closed")
default:
}
}

View File

@@ -20,5 +20,8 @@ func NewCallback(f func(*servicecontext.Context, *v1.Service, *sync.WaitGroup, b
}
func (c *Callback) Run(svcCtx *servicecontext.Context, svc *v1.Service, wg *sync.WaitGroup) error {
if c == nil || c.Function == nil {
return nil
}
return c.Function(svcCtx, svc, wg, c.UsesLeaderElection)
}

View File

@@ -0,0 +1,31 @@
package services
import (
"errors"
"sync"
"testing"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
)
func TestCallbackRunWithoutFunction(t *testing.T) {
for _, callback := range []*Callback{nil, {}} {
if err := callback.Run(nil, nil, nil); err != nil {
t.Fatalf("Run error = %v", err)
}
}
}
func TestCallbackRunPropagatesLeaderElectionFlag(t *testing.T) {
want := errors.New("callback error")
callback := NewCallback(func(_ *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, usesLeaderElection bool) error {
if !usesLeaderElection {
t.Fatal("callback did not receive leader election flag")
}
return want
}, true)
if err := callback.Run(nil, nil, nil); !errors.Is(err, want) {
t.Fatalf("Run error = %v, want %v", err, want)
}
}

838
pkg/services/election.go Normal file
View File

@@ -0,0 +1,838 @@
package services
import (
"context"
log "log/slog"
"sort"
"strconv"
"sync"
"time"
"github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
// serviceElection owns local membership and campaign lifetime for one lease.
// Service contexts remain responsible for endpoint readiness and datapath work.
type serviceElection struct {
processor *Processor
id lease.ID
mutex sync.Mutex
members map[types.UID]*serviceElectionMember
lease *lease.Lease
campaign *serviceElectionCampaign
retired bool
retiredDone chan struct{}
retiredCtx context.Context
retireCancel context.CancelFunc
// restartFailures counts consecutive campaigns that ended via
// cancelCampaign (an activation failure with no other ready member)
// rather than a normal leadership change. It backs off campaign restarts
// and resets on the next successful activation.
restartFailures int
}
const (
serviceElectionRestartBaseDelay = 200 * time.Millisecond
serviceElectionRestartMaxDelay = 30 * time.Second
)
type serviceElectionCampaign struct {
done chan struct{}
ctx context.Context
cancel context.CancelFunc
leaderCtx context.Context
cancelLeader context.CancelFunc
vips []string
external bool
stopped bool
}
func (campaign *serviceElectionCampaign) cancelRunner() {
if campaign != nil && campaign.cancel != nil {
campaign.cancel()
}
}
type serviceElectionMember struct {
election *serviceElection
service *v1.Service
serviceContext *servicecontext.Context
readinessGeneration uint64
claimToken string
operationMutex sync.Mutex
active bool
}
func (p *Processor) serviceElectionFor(id lease.ID) *serviceElection {
p.electionsMutex.Lock()
defer p.electionsMutex.Unlock()
if p.elections == nil {
p.elections = make(map[string]*serviceElection)
}
key := id.NamespacedName()
if election := p.elections[key]; election != nil {
return election
}
retiredCtx, retire := context.WithCancel(context.Background())
election := &serviceElection{
processor: p,
id: id,
members: make(map[types.UID]*serviceElectionMember),
retiredDone: make(chan struct{}),
retiredCtx: retiredCtx,
retireCancel: retire,
}
p.elections[key] = election
return election
}
// joinServiceElection registers the current ready generation of a Service. A
// caller that races coordinator retirement retries against its replacement.
func (p *Processor) joinServiceElection(svcCtx *servicecontext.Context, service *v1.Service,
readinessGeneration uint64) (*serviceElectionMember, bool) {
if svcCtx == nil || service == nil || p.leaseMgr == nil {
return nil, false
}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
for {
if !p.serviceElectionContextCurrent(svcCtx, service, readinessGeneration) {
return nil, false
}
election := p.serviceElectionFor(id)
member, joined := election.join(svcCtx, service, readinessGeneration)
if joined {
if p.serviceElectionContextCurrent(svcCtx, service, readinessGeneration) {
return member, true
}
p.leaveServiceElection(member)
return nil, false
}
if retiredDone, retired := election.retirement(); retired {
select {
case <-svcCtx.Ctx.Done():
return nil, false
case <-retiredDone:
continue
}
}
return nil, false
}
}
// serviceElectionContextCurrent acquires the Service lock while comparing the
// current context. Callers must not already hold that lock.
func (p *Processor) serviceElectionContextCurrent(svcCtx *servicecontext.Context, service *v1.Service, readinessGeneration uint64) bool {
currentContext, err := p.currentServiceContext(service.UID)
if err != nil || currentContext != svcCtx || svcCtx.Ctx.Err() != nil {
return false
}
return svcCtx.ReadinessGenerationCurrent(readinessGeneration)
}
// currentServiceContext acquires and releases the Service lock around one
// svcMap read.
func (p *Processor) currentServiceContext(uid types.UID) (*servicecontext.Context, error) {
unlockService := p.lockService(uid)
defer unlockService()
return p.getServiceContext(uid)
}
func (e *serviceElection) join(svcCtx *servicecontext.Context, service *v1.Service,
readinessGeneration uint64) (*serviceElectionMember, bool) {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired {
return nil, false
}
if member := e.members[service.UID]; member != nil && member.serviceContext == svcCtx &&
member.readinessGeneration == readinessGeneration {
return member, true
}
if previous := e.members[service.UID]; previous != nil {
e.processor.leaseMgr.Delete(e.id, previous.claimToken, e.lease)
}
member := &serviceElectionMember{
election: e,
service: service.DeepCopy(),
serviceContext: svcCtx,
readinessGeneration: readinessGeneration,
claimToken: e.nextMemberToken(),
}
e.members[service.UID] = member
// A member can become ready again while the old campaign is still stopping.
// Keep its new generation until that runner finishes; finishCampaign will
// rebuild the lease and launch the replacement campaign.
if e.lease != nil && e.lease.Ctx.Err() != nil && e.campaign != nil {
return member, true
}
if e.lease == nil || e.lease.Ctx.Err() != nil {
if e.createLeaseLocked() == nil {
delete(e.members, service.UID)
return nil, false
}
return member, true
}
if claimed, _ := e.processor.leaseMgr.Claim(e.id, member.claimToken); claimed != nil {
return member, true
}
// An external cleanup retired the manager entry. Rebuild it from the live
// coordinator snapshot rather than admitting a member to a dead lease.
e.lease = nil
if e.createLeaseLocked() == nil {
delete(e.members, service.UID)
return nil, false
}
return member, true
}
func (e *serviceElection) createLeaseLocked() *lease.Lease {
if e.lease != nil && e.lease.Ctx.Err() == nil {
return e.lease
}
var first *serviceElectionMember
for _, member := range e.members {
first = member
break
}
if first == nil {
return nil
}
svcLease, _ := e.processor.leaseMgr.Acquire(context.Background(), e.id, first.claimToken)
for _, member := range e.members {
if member == first {
continue
}
if claimed, _ := e.processor.leaseMgr.Claim(e.id, member.claimToken); claimed == nil {
svcLease.Cancel()
return nil
}
}
e.lease = svcLease
return svcLease
}
func (e *serviceElection) nextMemberToken() string {
return strconv.FormatUint(e.processor.nextMemberToken.Add(1), 10)
}
// leaveServiceElection removes only the supplied member generation. A stale
// member cannot remove a replacement Service context or readiness generation.
func (p *Processor) leaveServiceElection(member *serviceElectionMember) {
if member == nil {
return
}
member.election.leave(member)
}
func (p *Processor) leaveServiceElectionForContext(svcCtx *servicecontext.Context, service *v1.Service) {
if svcCtx == nil || service == nil {
return
}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
election := p.currentServiceElection(id)
if election == nil {
return
}
member := election.currentMember(service.UID)
if member != nil && member.serviceContext == svcCtx {
p.leaveServiceElection(member)
}
}
func (p *Processor) currentServiceElection(id lease.ID) *serviceElection {
p.electionsMutex.Lock()
defer p.electionsMutex.Unlock()
return p.elections[id.NamespacedName()]
}
func (e *serviceElection) currentMember(uid types.UID) *serviceElectionMember {
e.mutex.Lock()
defer e.mutex.Unlock()
return e.members[uid]
}
func (e *serviceElection) leave(member *serviceElectionMember) {
campaign, leaseRetired, retired := e.removeMember(member)
if !retired {
return
}
e.retire()
if campaign != nil && (campaign.external || leaseRetired) {
campaign.cancelRunner()
}
// A Service-owned runner remains responsible for the shared election when
// a non-Service member still holds the lease. Its lease-scoped context ends
// when that final member leaves or the election itself stops.
}
// removeMember deletes member if it is still current and, once no members
// remain, marks the election retired and reports whether deleting its claim
// also retired the shared lease.
func (e *serviceElection) removeMember(member *serviceElectionMember) (campaign *serviceElectionCampaign, leaseRetired, retired bool) {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.members[member.service.UID] != member {
return nil, false, false
}
delete(e.members, member.service.UID)
leaseRetired = e.processor.leaseMgr.Delete(e.id, member.claimToken, e.lease)
if len(e.members) != 0 {
return nil, leaseRetired, false
}
e.retired = true
campaign = e.campaign
e.lease = nil
return campaign, leaseRetired, true
}
func (e *serviceElection) retirement() (<-chan struct{}, bool) {
e.mutex.Lock()
defer e.mutex.Unlock()
return e.retiredDone, e.retired
}
func (e *serviceElection) retire() {
if e.retireCancel != nil {
e.retireCancel()
}
e.processor.removeServiceElection(e)
close(e.retiredDone)
}
func (p *Processor) removeServiceElection(election *serviceElection) {
p.electionsMutex.Lock()
defer p.electionsMutex.Unlock()
if p.elections[election.id.NamespacedName()] == election {
delete(p.elections, election.id.NamespacedName())
}
}
func (p *Processor) watchServiceElection(svcCtx *servicecontext.Context, service *v1.Service,
wg *sync.WaitGroup) {
for {
generation, ready, lost, isReady := svcCtx.ReadinessState()
if !isReady {
select {
case <-svcCtx.Ctx.Done():
return
case <-ready:
continue
}
}
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
if !p.serviceElectionContextCurrent(svcCtx, service, generation) {
return
}
select {
case <-svcCtx.Ctx.Done():
return
case <-time.After(serviceElectionRestartBaseDelay):
continue
}
}
member.election.startCampaign(wg)
select {
case <-svcCtx.Ctx.Done():
member.election.deactivateMember(member)
p.leaveServiceElection(member)
return
case <-lost:
member.election.deactivateMember(member)
p.leaveServiceElection(member)
}
}
}
// campaignStart carries the decision taken under the election mutex so the
// caller can act on it without holding the lock.
type campaignStart struct {
lease *lease.Lease
campaign *serviceElectionCampaign
leaderCtx context.Context
members []*serviceElectionMember
joinExisting bool
external bool
}
func (e *serviceElection) prepareCampaign() campaignStart {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || len(e.members) == 0 {
return campaignStart{}
}
if e.campaign != nil {
return campaignStart{
lease: e.lease,
campaign: e.campaign,
leaderCtx: e.campaign.leaderCtx,
joinExisting: true,
}
}
svcLease := e.createLeaseLocked()
if svcLease == nil {
return campaignStart{}
}
external := !svcLease.BeginElection()
campaignCtx, campaignCancel := svcLease.NewElectionContext(context.Background())
members := e.membersLocked()
campaign := &serviceElectionCampaign{
done: make(chan struct{}),
ctx: campaignCtx,
cancel: campaignCancel,
vips: memberVIPs(members),
external: external,
}
e.campaign = campaign
return campaignStart{lease: svcLease, campaign: campaign, members: members, external: external}
}
func (e *serviceElection) startCampaign(wg *sync.WaitGroup) {
start := e.prepareCampaign()
if start.campaign == nil {
return
}
if start.joinExisting {
if start.leaderCtx != nil {
e.activateMembers(start.leaderCtx, start.lease, start.campaign, wg)
}
return
}
if start.external {
wg.Go(func() {
e.followCampaign(start.lease, start.campaign, wg)
})
return
}
for _, member := range start.members {
metrics.ServiceElectionAttemptsTotal.WithLabelValues(member.service.Namespace, member.service.Name).Inc()
}
wg.Go(func() {
e.runCampaign(start.lease, start.campaign, wg)
})
}
// adoptLeaderContext publishes the leader context for a campaign that just won
// an externally driven election.
func (e *serviceElection) adoptLeaderContext(svcLease *lease.Lease, campaign *serviceElectionCampaign,
leaderCtx context.Context, cancelLeader context.CancelFunc) bool {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign || campaign.stopped {
return false
}
campaign.leaderCtx = leaderCtx
campaign.cancelLeader = cancelLeader
return true
}
func (e *serviceElection) followCampaign(svcLease *lease.Lease, campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
defer close(campaign.done)
defer campaign.cancelRunner()
leaderGeneration, elected := svcLease.WaitForLeaderGeneration(campaign.ctx)
if !elected {
e.stopCampaign(svcLease, campaign)
e.finishCampaign(svcLease, campaign, wg)
return
}
leaderCtx, cancelLeader := context.WithCancel(campaign.ctx)
if !e.adoptLeaderContext(svcLease, campaign, leaderCtx, cancelLeader) {
cancelLeader()
return
}
e.activateMembers(leaderCtx, svcLease, campaign, wg)
svcLease.WaitForElectionEndAfter(campaign.ctx, leaderGeneration)
cancelLeader()
e.stopCampaign(svcLease, campaign)
e.finishCampaign(svcLease, campaign, wg)
}
func (e *serviceElection) runCampaign(svcLease *lease.Lease, campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
defer close(campaign.done)
defer campaign.cancelRunner()
run := election.RunConfig{
Config: e.processor.config,
LeaseID: e.id,
Mgr: e.processor.electionMgr,
LeaseAnnotations: map[string]string{},
VIPs: campaign.vips,
OnStartedLeading: func(ctx context.Context) {
e.startedLeading(ctx, svcLease, campaign, wg)
},
OnStoppedLeading: func() {
e.stopCampaign(svcLease, campaign)
metrics.IsLeader.WithLabelValues(e.processor.config.NodeName, e.id.Name()).Set(0)
},
OnNewLeader: func(identity string) {
if identity != e.processor.config.NodeName {
log.Info("new leader", "leader", identity, "lease", e.id.NamespacedName())
}
},
}
if err := e.processor.runElection(campaign.ctx, &run); err != nil {
log.Error("services election failed", "lease", e.id.NamespacedName(), "error", err)
}
e.stopCampaign(svcLease, campaign)
svcLease.ElectionStopped()
e.finishCampaign(svcLease, campaign, wg)
}
func memberVIPs(members []*serviceElectionMember) []string {
services := make([]*v1.Service, 0, len(members))
for _, member := range members {
if member != nil && member.service != nil {
services = append(services, member.service)
}
}
return orderedServiceVIPs(services)
}
func orderedServiceVIPs(services []*v1.Service) []string {
services = append([]*v1.Service(nil), services...)
sort.SliceStable(services, func(first, second int) bool {
firstService, secondService := services[first], services[second]
if !firstService.CreationTimestamp.Equal(&secondService.CreationTimestamp) {
return firstService.CreationTimestamp.Before(&secondService.CreationTimestamp)
}
if firstService.Namespace != secondService.Namespace {
return firstService.Namespace < secondService.Namespace
}
if firstService.Name != secondService.Name {
return firstService.Name < secondService.Name
}
return firstService.UID < secondService.UID
})
vips := make([]string, 0)
for _, service := range services {
addresses, _ := instance.FetchServiceAddresses(service)
vips = append(vips, addresses...)
}
return vips
}
func (p *Processor) runElection(ctx context.Context, run *election.RunConfig) error {
if p.electionRun != nil {
return p.electionRun(ctx, run, p.config)
}
return election.RunOrDie(ctx, run, p.config)
}
func (e *serviceElection) membersLocked() []*serviceElectionMember {
members := make([]*serviceElectionMember, 0, len(e.members))
for _, member := range e.members {
members = append(members, member)
}
return members
}
// beginLeading records the leader context for a campaign this process won.
func (e *serviceElection) beginLeading(ctx context.Context, svcLease *lease.Lease,
campaign *serviceElectionCampaign) bool {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign || campaign.stopped || len(e.members) == 0 {
return false
}
campaign.leaderCtx = ctx
svcLease.ElectionStarted()
return true
}
func (e *serviceElection) startedLeading(ctx context.Context, svcLease *lease.Lease,
campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
if !e.beginLeading(ctx, svcLease, campaign) {
return
}
metrics.LeaderTransitionsTotal.WithLabelValues(e.id.Name()).Inc()
metrics.IsLeader.WithLabelValues(e.processor.config.NodeName, e.id.Name()).Set(1)
e.activateMembers(ctx, svcLease, campaign, wg)
}
// activatableMembers snapshots the members eligible for activation, or nil when
// the campaign is no longer current.
func (e *serviceElection) activatableMembers(svcLease *lease.Lease,
campaign *serviceElectionCampaign) []*serviceElectionMember {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign || (campaign != nil && campaign.stopped) || !svcLease.Elected.Load() {
return nil
}
return e.membersLocked()
}
func (e *serviceElection) activateMembers(ctx context.Context, svcLease *lease.Lease,
campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
if svcLease == nil || !svcLease.Elected.Load() {
return
}
for _, member := range e.activatableMembers(svcLease, campaign) {
e.activateMember(ctx, member, svcLease, campaign, wg)
}
}
func (e *serviceElection) activateMember(ctx context.Context, member *serviceElectionMember, svcLease *lease.Lease,
campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
releaseReadiness, ready := member.serviceContext.AcquireReadinessGeneration(member.readinessGeneration)
if !ready {
return
}
defer releaseReadiness()
member.operationMutex.Lock()
defer member.operationMutex.Unlock()
if !e.processor.serviceElectionMemberCurrent(member) || !e.markMemberActive(member, svcLease, campaign) {
return
}
if err := e.processor.syncServices(ctx, member.serviceContext, member.service, wg, true); err != nil {
metrics.ServiceElectionErrorsTotal.WithLabelValues(member.service.Namespace, member.service.Name, "service_sync").Inc()
log.Error("start service after election", "service", member.service.Name, "namespace", member.service.Namespace, "error", err)
e.deactivateMemberOperationHeld(member)
if !e.hasOtherReadyMember(member) {
e.cancelCampaign(svcLease, campaign)
}
return
}
e.resetRestartFailures()
if !e.processor.serviceElectionMemberCurrent(member) || !e.memberActivationCurrent(member, svcLease, campaign) {
e.deactivateMemberOperationHeld(member)
}
}
func (e *serviceElection) resetRestartFailures() {
e.mutex.Lock()
defer e.mutex.Unlock()
e.restartFailures = 0
}
func (p *Processor) syncServices(operationCtx context.Context, svcCtx *servicecontext.Context,
service *v1.Service, wg *sync.WaitGroup, usesLeaderElection bool) error {
if p.serviceSync != nil {
return p.serviceSync(operationCtx, svcCtx, service, wg, usesLeaderElection)
}
return p.syncServicesWithContext(operationCtx, svcCtx, service, wg, usesLeaderElection)
}
func (e *serviceElection) memberActivationCurrent(member *serviceElectionMember, svcLease *lease.Lease,
campaign *serviceElectionCampaign) bool {
e.mutex.Lock()
defer e.mutex.Unlock()
return e.memberActivationCurrentLocked(member, svcLease, campaign) && member.active
}
func (e *serviceElection) memberActivationCurrentLocked(member *serviceElectionMember, svcLease *lease.Lease,
campaign *serviceElectionCampaign) bool {
return !e.retired && e.lease == svcLease && e.campaign == campaign &&
(campaign == nil || !campaign.stopped) && svcLease.Elected.Load() &&
e.members[member.service.UID] == member
}
func (e *serviceElection) markMemberActive(member *serviceElectionMember, svcLease *lease.Lease, campaign *serviceElectionCampaign) bool {
e.mutex.Lock()
defer e.mutex.Unlock()
if !e.memberActivationCurrentLocked(member, svcLease, campaign) || member.active {
return false
}
member.active = true
return true
}
func (e *serviceElection) hasOtherReadyMember(member *serviceElectionMember) bool {
for _, candidate := range e.otherMembers(member) {
if candidate.serviceContext.Ctx.Err() == nil && candidate.serviceContext.ReadinessGenerationCurrent(candidate.readinessGeneration) {
return true
}
}
return false
}
// otherMembers returns every member except the supplied one.
func (e *serviceElection) otherMembers(member *serviceElectionMember) []*serviceElectionMember {
e.mutex.Lock()
defer e.mutex.Unlock()
others := make([]*serviceElectionMember, 0, len(e.members))
for _, candidate := range e.members {
if candidate != member {
others = append(others, candidate)
}
}
return others
}
func (e *serviceElection) deactivateMember(member *serviceElectionMember) {
member.operationMutex.Lock()
defer member.operationMutex.Unlock()
e.deactivateMemberOperationHeld(member)
}
// markMemberInactive clears the active flag and reports the lease that the
// caller must run cleanup against.
func (e *serviceElection) markMemberInactive(member *serviceElectionMember) (*lease.Lease, bool) {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.members[member.service.UID] != member || !member.active {
return nil, false
}
member.active = false
return e.lease, true
}
func (e *serviceElection) deactivateMemberOperationHeld(member *serviceElectionMember) {
svcLease, deactivated := e.markMemberInactive(member)
if !deactivated {
return
}
e.cleanupMember(member, svcLease)
}
// serviceElectionMemberCurrent acquires and releases the Service lock before
// acquiring the election mutex. Callers must not already hold the Service lock.
func (p *Processor) serviceElectionMemberCurrent(member *serviceElectionMember) bool {
currentCtx, err := p.currentServiceContext(member.service.UID)
current := err == nil && currentCtx == member.serviceContext && member.serviceContext.Ctx.Err() == nil &&
member.serviceContext.ReadinessGenerationCurrent(member.readinessGeneration)
if !current {
return false
}
member.election.mutex.Lock()
defer member.election.mutex.Unlock()
return !member.election.retired && member.election.members[member.service.UID] == member
}
func (e *serviceElection) cleanupMember(member *serviceElectionMember, svcLease *lease.Lease) {
if svcLease == nil {
return
}
if err := e.processor.onStoppedLeadingMember(member, svcLease); err != nil {
log.Error("stop service after election", "service", member.service.Name, "namespace", member.service.Namespace, "error", err)
}
}
// markCampaignStopped retires the campaign and returns the members whose
// datapath the caller must tear down outside the lock.
func (e *serviceElection) markCampaignStopped(svcLease *lease.Lease,
campaign *serviceElectionCampaign) []*serviceElectionMember {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign || campaign.stopped {
return nil
}
campaign.stopped = true
if campaign.cancelLeader != nil {
campaign.cancelLeader()
}
if !campaign.external {
svcLease.ElectionStopped()
}
return e.membersLocked()
}
func (e *serviceElection) stopCampaign(svcLease *lease.Lease, campaign *serviceElectionCampaign) {
for _, member := range e.markCampaignStopped(svcLease, campaign) {
e.deactivateMember(member)
}
}
// recordCampaignFailure counts an activation failure for the restart backoff.
func (e *serviceElection) recordCampaignFailure(svcLease *lease.Lease, campaign *serviceElectionCampaign) bool {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign {
return false
}
e.restartFailures++
return true
}
func (e *serviceElection) cancelCampaign(svcLease *lease.Lease, campaign *serviceElectionCampaign) {
if !e.recordCampaignFailure(svcLease, campaign) {
return
}
campaign.cancelRunner()
}
// completeCampaign clears the finished campaign and reports whether a restart
// is still needed, along with its backoff delay.
func (e *serviceElection) completeCampaign(svcLease *lease.Lease,
campaign *serviceElectionCampaign) (bool, time.Duration) {
e.mutex.Lock()
defer e.mutex.Unlock()
if e.retired || e.lease != svcLease || e.campaign != campaign {
return false, 0
}
e.campaign = nil
if svcLease.Ctx.Err() != nil {
e.lease = nil
}
return len(e.members) != 0, e.restartDelayLocked()
}
func (e *serviceElection) finishCampaign(svcLease *lease.Lease, campaign *serviceElectionCampaign, wg *sync.WaitGroup) {
restart, delay := e.completeCampaign(svcLease, campaign)
if !restart {
return
}
e.processor.scheduleServiceElectionRestart(e.retiredCtx, delay, wg, func() {
e.startCampaign(wg)
})
}
// restartDelayLocked doubles the restart delay for each consecutive
// activation failure, capped at serviceElectionRestartMaxDelay, so a
// persistently broken Service does not spin the Lease and VIP in a tight
// add/delete loop. The caller must hold e.mutex.
func (e *serviceElection) restartDelayLocked() time.Duration {
delay := serviceElectionRestartBaseDelay
for i := 0; i < e.restartFailures && delay < serviceElectionRestartMaxDelay; i++ {
delay *= 2
}
if delay > serviceElectionRestartMaxDelay {
delay = serviceElectionRestartMaxDelay
}
return delay
}
func (p *Processor) scheduleServiceElectionRestart(ctx context.Context, delay time.Duration, wg *sync.WaitGroup, restart func()) {
if p.scheduleElectionRestart != nil {
p.scheduleElectionRestart(restart)
return
}
wg.Go(func() {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
restart()
}
})
}

View File

@@ -0,0 +1,596 @@
package services
import (
"context"
"errors"
"slices"
"sync"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
func resetServiceReadiness(t *testing.T, svcCtx *servicecontext.Context) {
t.Helper()
generation, _, _, ready := svcCtx.ReadinessState()
if !ready || !svcCtx.ResetReadinessGeneration(generation) {
t.Fatal("Service readiness generation was not reset")
}
}
func TestOrderedServiceVIPsUsesCreationTimeAndStableIdentity(t *testing.T) {
older := metav1.NewTime(time.Unix(100, 0))
newer := metav1.NewTime(time.Unix(200, 0))
services := []*v1.Service{
{ObjectMeta: metav1.ObjectMeta{Name: "new", Namespace: "default", UID: "new", CreationTimestamp: newer}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.30"}},
{ObjectMeta: metav1.ObjectMeta{Name: "second", Namespace: "default", UID: "second", CreationTimestamp: older}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.20"}},
{ObjectMeta: metav1.ObjectMeta{Name: "first", Namespace: "default", UID: "first", CreationTimestamp: older}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10"}},
}
got := orderedServiceVIPs(services)
want := []string{"192.0.2.10", "192.0.2.20", "192.0.2.30"}
if !slices.Equal(got, want) {
t.Fatalf("orderedServiceVIPs() = %v, want %v", got, want)
}
}
func TestServiceElectionClaimsOnlyCurrentReadinessGeneration(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
svcCtx := servicecontext.New(context.Background())
defer svcCtx.Cancel()
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
firstGeneration, _, _, _ := svcCtx.ReadinessState()
first, joined := p.joinServiceElection(svcCtx, service, firstGeneration)
if !joined {
t.Fatal("first ready generation did not join its service election")
}
if first.claimToken == "" || first.claimToken == string(service.UID) {
t.Fatalf("claim token %q is not a coordinator-issued opaque token", first.claimToken)
}
p.leaveServiceElection(first)
resetServiceReadiness(t, svcCtx)
svcCtx.SignalReadiness()
secondGeneration, _, _, _ := svcCtx.ReadinessState()
second, joined := p.joinServiceElection(svcCtx, service, secondGeneration)
if !joined {
t.Fatal("second ready generation did not join its service election")
}
if second.claimToken == first.claimToken {
t.Fatal("new readiness generation reused the old lease claim token")
}
p.removeServiceElection(first.election)
p.electionsMutex.Lock()
current := p.elections[id.NamespacedName()]
p.electionsMutex.Unlock()
if current != second.election {
t.Fatal("retired coordinator cleanup removed its replacement")
}
// Delayed cleanup from the old generation must not remove the new claim.
p.leaveServiceElection(first)
if p.leaseMgr.Get(id) == nil {
t.Fatal("stale generation cleanup retired the current service election")
}
p.leaveServiceElection(second)
if p.leaseMgr.Get(id) != nil {
t.Fatal("final member withdrawal did not retire its lease")
}
}
func TestServiceElectionStaleUIDCannotReleaseRecreatedService(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
oldService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("old"), Annotations: annotations,
}}
newService := oldService.DeepCopy()
newService.UID = types.UID("new")
siblingService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "sibling", Namespace: "default", UID: types.UID("sibling"), Annotations: annotations,
}}
oldCtx := servicecontext.New(context.Background())
siblingCtx := servicecontext.New(context.Background())
p.svcMap.Store(oldService.UID, oldCtx)
p.svcMap.Store(siblingService.UID, siblingCtx)
oldCtx.SignalReadiness()
siblingCtx.SignalReadiness()
oldGeneration, _, _, _ := oldCtx.ReadinessState()
oldMember, joined := p.joinServiceElection(oldCtx, oldService, oldGeneration)
if !joined {
t.Fatal("old Service did not join its election")
}
siblingGeneration, _, _, _ := siblingCtx.ReadinessState()
siblingMember, joined := p.joinServiceElection(siblingCtx, siblingService, siblingGeneration)
if !joined {
t.Fatal("sibling Service did not join its election")
}
originalElection := oldMember.election
originalLease := originalElection.lease
newCtx := servicecontext.New(context.Background())
p.svcMap.Store(newService.UID, newCtx)
newCtx.SignalReadiness()
newGeneration, _, _, _ := newCtx.ReadinessState()
newMember, joined := p.joinServiceElection(newCtx, newService, newGeneration)
if !joined {
t.Fatal("recreated Service did not join its election")
}
p.leaveServiceElection(oldMember)
if newMember.election != originalElection || newMember.election.lease != originalLease {
t.Fatal("recreated Service did not join the sibling's live election")
}
originalElection.mutex.Lock()
currentNew := originalElection.members[newService.UID]
currentSibling := originalElection.members[siblingService.UID]
originalElection.mutex.Unlock()
if currentNew != newMember || currentSibling != siblingMember {
t.Fatal("stale Service UID cleanup removed a live shared-lease member")
}
p.leaveServiceElection(newMember)
p.leaveServiceElection(siblingMember)
}
func TestServiceElectionActivatesMemberJoiningActiveCampaign(t *testing.T) {
p := &Processor{
config: &kubevip.Config{EnableServicesElection: true},
leaseMgr: lease.NewManager(),
}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
firstService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", UID: types.UID("first"), Annotations: annotations,
}}
secondService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "second", Namespace: "default", UID: types.UID("second"), Annotations: annotations,
}}
firstCtx := servicecontext.New(context.Background())
secondCtx := servicecontext.New(context.Background())
p.svcMap.Store(firstService.UID, firstCtx)
p.svcMap.Store(secondService.UID, secondCtx)
firstCtx.SignalReadiness()
firstGeneration, _, _, _ := firstCtx.ReadinessState()
first, joined := p.joinServiceElection(firstCtx, firstService, firstGeneration)
if !joined {
t.Fatal("first member did not join its election")
}
election := first.election
election.mutex.Lock()
election.campaign = &serviceElectionCampaign{done: make(chan struct{}), leaderCtx: context.Background()}
svcLease := election.lease
election.mutex.Unlock()
svcLease.ElectionStarted()
secondCtx.SignalReadiness()
secondGeneration, _, _, _ := secondCtx.ReadinessState()
second, joined := p.joinServiceElection(secondCtx, secondService, secondGeneration)
if !joined {
t.Fatal("late member did not join its election")
}
election.startCampaign(&sync.WaitGroup{})
election.mutex.Lock()
active := second.active
election.mutex.Unlock()
if !active {
t.Fatal("member joining an active campaign was not activated")
}
election.mutex.Lock()
election.campaign = nil
election.mutex.Unlock()
p.leaveServiceElection(first)
p.leaveServiceElection(second)
}
func TestServiceElectionLeadershipLossWaitsForMemberActivation(t *testing.T) {
syncStarted := make(chan struct{})
releaseSync := make(chan struct{})
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
var p *Processor
p = &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
serviceSync: func(_ context.Context, _ *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, _ bool) error {
close(syncStarted)
<-releaseSync
unlockService := p.lockService(service.UID)
p.appendServiceInstance(&instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy(), AddCalled: true})
unlockService()
return nil
},
}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join its election")
}
election := member.election
campaign := &serviceElectionCampaign{done: make(chan struct{})}
election.mutex.Lock()
election.campaign = campaign
svcLease := election.lease
election.mutex.Unlock()
svcLease.ElectionStarted()
activationDone := make(chan struct{})
go func() {
election.activateMember(context.Background(), member, svcLease, campaign, &sync.WaitGroup{})
close(activationDone)
}()
<-syncStarted
stopDone := make(chan struct{})
go func() {
election.stopCampaign(svcLease, campaign)
close(stopDone)
}()
waitForCondition(t, func() bool {
election.mutex.Lock()
defer election.mutex.Unlock()
return campaign.stopped
}, "campaign leadership loss")
select {
case <-stopDone:
t.Fatal("campaign stop completed before in-flight member activation")
default:
}
close(releaseSync)
select {
case <-activationDone:
case <-time.After(time.Second):
t.Fatal("member activation did not finish")
}
select {
case <-stopDone:
case <-time.After(time.Second):
t.Fatal("campaign stop did not finish after member activation")
}
if svcLease.Elected.Load() {
t.Fatal("lease remained elected after campaign stop")
}
election.mutex.Lock()
active := member.active
election.mutex.Unlock()
if active {
t.Fatal("member remained active after campaign stop")
}
if p.findServiceInstance(service) != nil {
t.Fatal("in-flight activation left a tracked Service instance after leadership loss")
}
p.leaveServiceElection(member)
}
func TestServiceElectionLeadershipLossCancelsMemberActivation(t *testing.T) {
syncStarted := make(chan struct{})
cancellationObserved := make(chan struct{})
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
serviceSync: func(ctx context.Context, _ *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, _ bool) error {
close(syncStarted)
<-ctx.Done()
close(cancellationObserved)
return ctx.Err()
},
}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join its election")
}
election := member.election
leaderCtx, cancelLeader := context.WithCancel(context.Background())
campaign := &serviceElectionCampaign{done: make(chan struct{}), leaderCtx: leaderCtx, cancelLeader: cancelLeader}
election.mutex.Lock()
election.campaign = campaign
svcLease := election.lease
election.mutex.Unlock()
svcLease.ElectionStarted()
activationDone := make(chan struct{})
go func() {
election.activateMember(leaderCtx, member, svcLease, campaign, &sync.WaitGroup{})
close(activationDone)
}()
<-syncStarted
stopDone := make(chan struct{})
go func() {
election.stopCampaign(svcLease, campaign)
close(stopDone)
}()
select {
case <-cancellationObserved:
case <-time.After(time.Second):
t.Fatal("leadership loss did not cancel member activation")
}
select {
case <-activationDone:
case <-time.After(time.Second):
t.Fatal("member activation did not finish after cancellation")
}
select {
case <-stopDone:
case <-time.After(time.Second):
t.Fatal("campaign stop did not finish after activation cancellation")
}
p.leaveServiceElection(member)
}
func TestServiceElectionActivationFailureAfterMemberRemovalDoesNotPanic(t *testing.T) {
syncStarted := make(chan struct{})
releaseSync := make(chan struct{})
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
serviceSync: func(_ context.Context, _ *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, _ bool) error {
close(syncStarted)
<-releaseSync
return errors.New("activation failed")
},
}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join its election")
}
election := member.election
campaign := &serviceElectionCampaign{done: make(chan struct{})}
election.mutex.Lock()
election.campaign = campaign
svcLease := election.lease
election.mutex.Unlock()
svcLease.ElectionStarted()
activationDone := make(chan any, 1)
go func() {
defer func() {
activationDone <- recover()
}()
election.activateMember(context.Background(), member, svcLease, campaign, &sync.WaitGroup{})
}()
<-syncStarted
p.leaveServiceElection(member)
close(campaign.done)
close(releaseSync)
select {
case panicValue := <-activationDone:
if panicValue != nil {
t.Fatalf("activation failure after member removal panicked: %v", panicValue)
}
case <-time.After(time.Second):
t.Fatal("member activation did not finish")
}
}
func TestStopCampaignDoesNotCleanupInactiveMember(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "inactive", Namespace: "default", UID: types.UID("inactive"),
}}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("inactive Service did not join its election")
}
serviceInstance := &instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}
p.ServiceInstances = []*instance.Instance{serviceInstance}
election := member.election
campaign := &serviceElectionCampaign{done: make(chan struct{})}
election.mutex.Lock()
election.campaign = campaign
svcLease := election.lease
election.mutex.Unlock()
svcLease.ElectionStarted()
election.stopCampaign(svcLease, campaign)
if got := p.findServiceInstance(service); got != serviceInstance {
t.Fatal("campaign stop detached an inactive member's instance")
}
election.mutex.Lock()
active := member.active
election.mutex.Unlock()
if active {
t.Fatal("inactive member became active during campaign stop")
}
p.leaveServiceElection(member)
}
func TestCancelledContextDrainsActiveMemberBeforeReplacement(t *testing.T) {
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager()}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
svcCtx := servicecontext.New(context.Background())
if !svcCtx.StartWatching() {
t.Fatal("watcher ownership was not acquired")
}
p.svcMap.Store(service.UID, svcCtx)
p.ServiceInstances = []*instance.Instance{{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy(), AddCalled: true}}
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join election")
}
member.election.mutex.Lock()
member.active = true
member.election.mutex.Unlock()
svcCtx.Cancel()
replacement := make(chan *servicecontext.Context, 1)
errs := make(chan error, 1)
go func() {
current, err := p.ensureServiceContext(context.Background(), service)
if err != nil {
errs <- err
return
}
replacement <- current
}()
select {
case <-replacement:
t.Fatal("replacement was created before active-member cleanup")
case err := <-errs:
t.Fatalf("ensureServiceContext() error = %v", err)
case <-time.After(20 * time.Millisecond):
}
member.election.deactivateMember(member)
p.leaveServiceElection(member)
svcCtx.StopWatching()
select {
case current := <-replacement:
if current == svcCtx || current.Ctx.Err() != nil {
t.Fatal("replacement context is not live")
}
case err := <-errs:
t.Fatalf("ensureServiceContext() error = %v", err)
case <-time.After(time.Second):
t.Fatal("replacement was not created after cleanup drained")
}
if p.findServiceInstance(service) != nil {
t.Fatal("active member datapath was not cleaned before replacement")
}
}
func TestServiceElectionDelayedCleanupDoesNotDeleteNewReadinessGeneration(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
}}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
p.ServiceInstances = []*instance.Instance{{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}}
svcCtx.SignalReadiness()
firstGeneration, _, _, _ := svcCtx.ReadinessState()
first, joined := p.joinServiceElection(svcCtx, service, firstGeneration)
if !joined {
t.Fatal("first readiness generation did not join")
}
resetServiceReadiness(t, svcCtx)
svcCtx.SignalReadiness()
secondGeneration, _, _, _ := svcCtx.ReadinessState()
second, joined := p.joinServiceElection(svcCtx, service, secondGeneration)
if !joined {
t.Fatal("second readiness generation did not join")
}
if err := p.onStoppedLeadingMember(first, first.election.lease); err != nil {
t.Fatalf("delayed old-generation cleanup returned an error: %v", err)
}
if p.findServiceInstance(service) == nil {
t.Fatal("delayed old-generation cleanup deleted the current Service instance")
}
p.leaveServiceElection(second)
}
// TestServiceElectionRestartDelayBacksOffOnRepeatedFailuresAndResets guards
// against a persistent activation failure turning into a tight Lease
// acquire/release and VIP add/delete loop: the restart delay must grow after
// a failure and drop back to the base delay once activation succeeds.
func TestServiceElectionRestartDelayBacksOffOnRepeatedFailuresAndResets(t *testing.T) {
id := lease.NewID("kubernetes", "default", "svc")
svcLease := lease.NewManager().Add(context.Background(), id)
campaign := &serviceElectionCampaign{done: make(chan struct{})}
election := &serviceElection{lease: svcLease, campaign: campaign}
baseDelay := serviceElectionRestartBaseDelay
election.cancelCampaign(svcLease, campaign)
election.mutex.Lock()
afterFailure := election.restartDelayLocked()
election.mutex.Unlock()
if afterFailure <= baseDelay {
t.Fatalf("restart delay did not grow after a failure: base=%v after=%v", baseDelay, afterFailure)
}
election.resetRestartFailures()
election.mutex.Lock()
afterReset := election.restartDelayLocked()
election.mutex.Unlock()
if afterReset != baseDelay {
t.Fatalf("restart delay was not reset after a successful activation: got=%v want=%v", afterReset, baseDelay)
}
}
func TestServiceElectionRestartTimerStopsOnRetirement(t *testing.T) {
p := &Processor{}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
restarted := make(chan struct{}, 1)
p.scheduleServiceElectionRestart(ctx, time.Hour, &wg, func() { restarted <- struct{}{} })
cancel()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("restart timer did not drain after coordinator retirement")
}
select {
case <-restarted:
t.Fatal("restart ran after coordinator retirement")
default:
}
}

View File

@@ -7,7 +7,6 @@ import (
log "log/slog"
"github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
@@ -30,181 +29,63 @@ func (p *Processor) StartServicesWatchForLeaderElection(ctx context.Context, for
return nil
}
// The startServicesWatchForLeaderElection function will start a services watcher, the
func (p *Processor) StartServicesLeaderElection(svcCtx *servicecontext.Context, service *v1.Service, _ *sync.WaitGroup, _ bool) error {
// StartServicesLeaderElection watches one Service's endpoint readiness while
// its per-lease coordinator owns campaign lifetime.
func (p *Processor) StartServicesLeaderElection(svcCtx *servicecontext.Context, service *v1.Service,
wg *sync.WaitGroup, _ bool) error {
if service == nil {
return fmt.Errorf("no service for leader election")
}
if svcCtx == nil {
return fmt.Errorf("no context context for service %q with UID %q: nil context", service.Name, service.UID)
return fmt.Errorf("no context for service %q with UID %q", service.Name, service.UID)
}
leaseNamespace, serviceLease := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, leaseNamespace, serviceLease)
objectName := lease.ServiceNamespacedName(service)
svcLease := p.leaseMgr.Get(id)
if svcLease == nil {
metrics.ServiceElectionErrorsTotal.WithLabelValues(service.Namespace, service.Name, "no_lease").Inc()
return fmt.Errorf("no existing lease found for service %q with UID %q", service.Name, service.UID)
currentContext, err := p.getServiceContext(service.UID)
if err != nil {
return fmt.Errorf("get current service context: %w", err)
}
if currentContext != svcCtx {
return fmt.Errorf("service context is no longer current for service %q with UID %q", service.Name, service.UID)
}
// A cancelled service context means this call belongs to a torn-down incarnation of
// the service. Its replacement is built as Cancel -> Delete -> Add, so the lease
// fetched above may already be the replacement's. Registering on it here would let
// the cleanup goroutine below retire a lease that is still in use.
if err := svcCtx.Ctx.Err(); err != nil {
return fmt.Errorf("service context cancelled before election start: %w", err)
}
isNew := svcLease.Add(objectName)
svcLease.Lock()
defer func() {
svcLease.Unlock()
}()
// this service was already processed so we do not need to do anything
if !isNew && svcLease.Elected.Load() {
svcLease.Unlock()
log.Debug("this service was already handled, waiting for it to finish", "service", service.Name, "uid", service.UID)
// Wait for either the service context or lease context to be done
select {
case <-svcCtx.Ctx.Done():
case <-svcLease.Ctx.Done():
}
if _, loaded := p.electionLoops.LoadOrStore(svcCtx, struct{}{}); loaded {
return nil
}
wg := sync.WaitGroup{}
defer wg.Wait()
// Start a goroutine that will delete the lease when the service context is cancelled.
// This is important for proper cleanup when a service is deleted - it ensures that
// the lease context (svcLease.Ctx) gets cancelled, which causes RunOrDie to return.
// Without this, RunOrDie would continue running until leadership is naturally lost.
//
// This must NOT be tracked by wg: it only completes once the service is deleted
// (svcCtx.Ctx.Done()), which is normally long after this function itself returns
// e.g. on an ordinary leadership loss such as a lease renewal failure. If it were
// added to wg, the deferred wg.Wait() above would block this function and with it
// the leader-election restart loop in startLeaderElection that calls it forever,
// until the Service was deleted (and recreated), even though the endpoint was still
// healthy and a new election should have started immediately.
go func() {
<-svcCtx.Ctx.Done()
p.leaseMgr.Delete(id, objectName, svcLease)
}()
select {
case <-svcCtx.Ctx.Done():
return fmt.Errorf("service context cancelled before election start: %w", svcCtx.Ctx.Err())
case <-svcLease.Ctx.Done():
return fmt.Errorf("lease context cancelled before election start: %w", svcLease.Ctx.Err())
case <-svcCtx.GetEndpointsReady():
defer p.electionLoops.Delete(svcCtx)
if wg == nil {
wg = &sync.WaitGroup{}
}
// this service is sharing lease with another service
if svcLease.Elected.Load() {
svcLease.Unlock()
// wait for leader election to start or context to be done
select {
case <-svcLease.Started:
case <-svcLease.Ctx.Done():
// Lease was cancelled (e.g., leader election ended), return immediately
// This allows the restart loop to create a fresh lease
log.Debug("lease context cancelled before leader election started", "service", service.Name, "uid", service.UID)
return nil
}
if err := p.onStartedLeading(svcCtx, service, &wg); err != nil {
log.Error("error on started leading", "error", err)
}
// Block until service context is cancelled
<-svcCtx.Ctx.Done()
if err := p.onStoppedLeading(svcCtx, svcLease, service); err != nil {
log.Error("error on stopped leading", "error", err)
}
// wait for leaderelection to be finished
<-svcLease.Ctx.Done()
return nil
}
log.Info("new leader election", "service", service.Name, "namespace", service.Namespace, "lock_name", serviceLease, "host_id", p.config.NodeName)
leaderCtx, leaderCancel := context.WithCancel(svcLease.Ctx)
svcCtx.SetLeaderCancel(leaderCancel)
run := election.RunConfig{
Config: p.config,
LeaseID: id,
Mgr: p.electionMgr,
LeaseAnnotations: map[string]string{},
OnStartedLeading: func(_ context.Context) {
svcLease.Elected.Store(true)
svcLease.Unlock()
close(svcLease.Started)
// Mark this service as active (as we've started leading)
// we run this in background as it's blocking
if err := p.onStartedLeading(svcCtx, service, &wg); err != nil {
leaderCancel()
}
metrics.LeaderTransitionsTotal.WithLabelValues(id.Name()).Inc()
metrics.IsLeader.WithLabelValues(p.config.NodeName, id.Name()).Set(1)
},
OnStoppedLeading: func() {
// we can do cleanup here
svcLease.Elected.Store(false)
log.Info("leadership lost", "service", service.Name, "uid", service.UID, "leader", p.config.NodeName)
if err := p.onStoppedLeading(svcCtx, svcLease, service); err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(service.Namespace, service.Name, "delete_service").Inc()
leaderCancel()
}
metrics.IsLeader.WithLabelValues(p.config.NodeName, id.Name()).Set(0)
svcLease.Started = make(chan any)
},
OnNewLeader: func(identity string) {
// we're notified when new leader elected
if identity == p.config.NodeName {
// I just got the lock
return
}
log.Info("new leader", "leader", identity, "service", service.Name, "uid", service.UID)
},
}
if err := election.RunOrDie(leaderCtx, &run, p.config); err != nil {
return fmt.Errorf("services election failed: %w", err)
}
log.Info("stopping leader election", "service", service.Name, "uid", service.UID)
loops := metrics.ServiceElectionLoops.WithLabelValues(service.Namespace, service.Name)
loops.Inc()
defer loops.Dec()
p.watchServiceElection(svcCtx, service, wg)
return nil
}
func (p *Processor) onStartedLeading(svcCtx *servicecontext.Context, service *v1.Service, wg *sync.WaitGroup) error {
err := p.SyncServices(svcCtx, service, wg, true)
if err != nil {
log.Error("service sync", "uid", service.UID, "err", err)
return err
}
return nil
}
// onStoppedLeadingMember acquires the Service lock and holds it through member
// validation and datapath cleanup. Callers must not already hold that lock.
func (p *Processor) onStoppedLeadingMember(member *serviceElectionMember, svcLease *lease.Lease) error {
unlockService := p.lockService(member.service.UID)
defer unlockService()
func (p *Processor) onStoppedLeading(svcCtx *servicecontext.Context, svcLease *lease.Lease, service *v1.Service) error {
currentSvcCtx, err := p.getServiceContext(service.UID)
currentSvcCtx, err := p.getServiceContext(member.service.UID)
if err != nil {
return err
}
if currentSvcCtx != nil && currentSvcCtx != svcCtx {
log.Debug("skipping cleanup from superseded service context", "service", service.Name, "uid", service.UID)
if currentSvcCtx != member.serviceContext {
log.Debug("skipping cleanup from superseded service context", "service", member.service.Name, "uid", member.service.UID)
return nil
}
log.Debug("deleting service due to lost leadership", "uid", service.UID)
err = p.deleteService(svcLease.Ctx, service.UID)
currentMember := member.election.currentMember(member.service.UID)
if currentMember != member {
log.Debug("skipping cleanup from superseded readiness generation", "service", member.service.Name, "uid", member.service.UID)
return nil
}
log.Debug("deleting service due to lost leadership", "uid", member.service.UID)
err = p.deleteCurrentServiceByUID(context.WithoutCancel(svcLease.Ctx), member.service.UID)
if err != nil {
log.Error("service deletion", "err", err)
return err

View File

@@ -2,70 +2,676 @@ package services
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/prometheus/client_golang/prometheus/testutil"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
// TestStartServicesLeaderElection_ReturnsOnLeaseLossWithoutServiceDeletion is a regression test for
// the issue: StartServicesLeaderElection deadlocked forever whenever it returned for
// any reason other than the Service itself being deleted.
//
// The function starts a lease-cleanup goroutine that only exits once svcCtx.Ctx is cancelled (Service
// deleted), then defers wg.Wait() on the same WaitGroup that goroutine belonged to. Since the Service
// stays alive across an ordinary lease loss, that goroutine and therefore the deferred wg.Wait()
// never returned, permanently wedging the leader-election restart loop in startLeaderElection for that
// service. The only workaround was to delete and recreate the Service.
func TestStartServicesLeaderElection_ReturnsOnLeaseLossWithoutServiceDeletion(t *testing.T) {
func TestStartServicesLeaderElectionTracksSharedMembersAcrossReadinessLoss(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
config: &kubevip.Config{EnableServicesElection: true},
leaseMgr: lease.NewManager(),
}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
firstService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", UID: types.UID("first"), Annotations: annotations,
}}
secondService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "second", Namespace: "default", UID: types.UID("second"), Annotations: annotations,
}}
namespace, name := lease.ServiceName(firstService)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
sharedLease := p.leaseMgr.Add(context.Background(), id)
sharedLease.ElectionStarted()
svc := &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "matst-example",
Namespace: "dsm-system",
UID: types.UID("test-uid"),
},
firstCtx := servicecontext.New(context.Background())
secondCtx := servicecontext.New(context.Background())
p.svcMap.Store(firstService.UID, firstCtx)
p.svcMap.Store(secondService.UID, secondCtx)
firstDone := make(chan error, 1)
secondDone := make(chan error, 1)
var wg sync.WaitGroup
go func() { firstDone <- p.StartServicesLeaderElection(firstCtx, firstService, &wg, true) }()
go func() { secondDone <- p.StartServicesLeaderElection(secondCtx, secondService, &wg, true) }()
firstCtx.SignalReadiness()
secondCtx.SignalReadiness()
election := waitForServiceElectionMembers(t, p, id, 2)
election.mutex.Lock()
firstToken := election.members[firstService.UID].claimToken
election.mutex.Unlock()
resetServiceReadiness(t, firstCtx)
waitForServiceElectionMembers(t, p, id, 1)
if !sharedLease.Elected.Load() || p.leaseMgr.Get(id) != sharedLease {
t.Fatal("one shared member losing readiness ended the healthy sibling campaign")
}
leaseNamespace, serviceLease := lease.ServiceName(svc)
id := lease.NewID(p.config.LeaderElectionType, leaseNamespace, serviceLease)
svcLease := p.leaseMgr.Add(context.Background(), id)
firstCtx.SignalReadiness()
election = waitForServiceElectionMembers(t, p, id, 2)
election.mutex.Lock()
secondToken := election.members[firstService.UID].claimToken
election.mutex.Unlock()
if secondToken == firstToken {
t.Fatal("readiness recovery reused the prior member claim token")
}
// Simulate ordinary leadership/lease loss (e.g. a renewal failure): the lease context ends
// but the Service itself is untouched, so svcCtx.Ctx must stay alive.
svcLease.Cancel()
secondCtx.Cancel()
waitForServiceElectionMembers(t, p, id, 1)
if firstCtx.Ctx.Err() != nil || !sharedLease.Elected.Load() || p.leaseMgr.Get(id) != sharedLease {
t.Fatal("deleting one shared member ended the healthy sibling campaign")
}
firstCtx.Cancel()
if err := <-firstDone; err != nil {
t.Fatalf("first member returned error: %v", err)
}
if err := <-secondDone; err != nil {
t.Fatalf("second member returned error: %v", err)
}
if p.leaseMgr.Get(id) != nil {
t.Fatal("final shared member withdrawal did not retire the lease")
}
}
func TestServiceMemberLeavingDoesNotCancelControlPlaneLease(t *testing.T) {
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager()}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
Annotations: map[string]string{kubevip.ServiceLease: "shared"},
}}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
controlPlaneToken := lease.ObjectName(id, "cp")
sharedLease, _ := p.leaseMgr.Acquire(context.Background(), id, controlPlaneToken)
if !sharedLease.BeginElection() {
t.Fatal("control-plane election did not start")
}
sharedLease.ElectionStarted()
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join the control-plane lease")
}
p.leaveServiceElection(member)
if sharedLease.Ctx.Err() != nil || !sharedLease.Elected.Load() || p.leaseMgr.Get(id) != sharedLease {
t.Fatal("leaving Service member cancelled the control-plane lease")
}
p.leaseMgr.Delete(id, controlPlaneToken, sharedLease)
}
func TestServiceMemberDeactivatesWhenExternalElectionStops(t *testing.T) {
activated := make(chan struct{}, 1)
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
scheduleElectionRestart: func(func()) {},
serviceSync: func(_ context.Context, _ *servicecontext.Context, _ *v1.Service, _ *sync.WaitGroup, _ bool) error {
activated <- struct{}{}
return nil
},
}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
Annotations: map[string]string{kubevip.ServiceLease: "shared"},
}}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
controlPlaneToken := lease.ObjectName(id, "cp")
sharedLease, _ := p.leaseMgr.Acquire(context.Background(), id, controlPlaneToken)
if !sharedLease.BeginElection() {
t.Fatal("external election did not start")
}
sharedLease.ElectionStarted()
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
member, joined := p.joinServiceElection(svcCtx, service, generation)
if !joined {
t.Fatal("Service did not join the external election")
}
var wg sync.WaitGroup
member.election.startCampaign(&wg)
select {
case <-activated:
case <-time.After(time.Second):
t.Fatal("Service member was not activated by external leadership")
}
sharedLease.ElectionStopped()
wg.Wait()
member.election.mutex.Lock()
active := member.active
member.election.mutex.Unlock()
if active {
t.Fatal("Service member remained active after external leadership ended")
}
p.leaveServiceElection(member)
p.leaseMgr.Delete(id, controlPlaneToken, sharedLease)
}
func TestStartServicesLeaderElectionRejectsNilContext(t *testing.T) {
p := &Processor{}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service", UID: types.UID("service")}}
if err := p.StartServicesLeaderElection(nil, service, nil, true); err == nil {
t.Fatal("nil service context started leader election")
}
}
func TestStartServicesLeaderElectionStaleContextReturnsPromptly(t *testing.T) {
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager()}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "stale", Namespace: "default", UID: types.UID("stale")}}
staleContext := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, servicecontext.New(context.Background()))
done := make(chan error, 1)
go func() {
done <- p.StartServicesLeaderElection(svcCtx, svc, nil, true)
}()
go func() { done <- p.StartServicesLeaderElection(staleContext, service, nil, true) }()
select {
case <-done:
// Expected: the function must return promptly when only the lease - not the service -
// has gone away, so the restart loop can retry the election.
case <-time.After(5 * time.Second):
t.Fatal("StartServicesLeaderElection did not return after the lease context was " +
"cancelled while the service context remained alive; this reproduces the deadlock " +
"where leader election could never be retried for a live service")
case err := <-done:
if err == nil {
t.Fatal("stale service context returned nil error")
}
case <-time.After(time.Second):
t.Fatal("stale service context did not return promptly")
}
if svcCtx.Ctx.Err() != nil {
t.Fatal("service context should not have been cancelled by an ordinary lease loss")
}
// The lease-cleanup goroutine should still be running, waiting for the service to be
// deleted; confirm it is not left dangling forever by cancelling the service context now.
svcCtx.Cancel()
}
func TestStartServicesLeaderElectionRejectsTypedNilService(t *testing.T) {
p := &Processor{}
var service *v1.Service
if err := p.StartServicesLeaderElection(servicecontext.New(context.Background()), service, nil, true); err == nil {
t.Fatal("typed-nil service started leader election")
}
}
func TestStartServicesLeaderElectionDoesNotRegisterCancelledContext(t *testing.T) {
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager()}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "cancelled", Namespace: "default", UID: types.UID("cancelled")}}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.Cancel()
if err := p.StartServicesLeaderElection(svcCtx, service, nil, true); err == nil {
t.Fatal("cancelled service context started leader election")
}
namespace, name := lease.ServiceName(service)
if p.leaseMgr.Get(lease.NewID(p.config.LeaderElectionType, namespace, name)) != nil {
t.Fatal("cancelled service context registered a lease")
}
}
func TestStartServicesLeaderElectionRegistersOneMemberForConcurrentCalls(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{})}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "concurrent", Namespace: "default", UID: types.UID("concurrent")}}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
const callers = 32
start := make(chan struct{})
errors := make(chan error, callers)
for range callers {
go func() {
<-start
errors <- p.StartServicesLeaderElection(svcCtx, service, nil, true)
}()
}
close(start)
waitForElectionRunner(t, runner.started)
namespace, name := lease.ServiceName(service)
waitForServiceElectionMembers(t, p, lease.NewID(p.config.LeaderElectionType, namespace, name), 1)
if got := runner.starts.Load(); got != 1 {
t.Fatalf("campaign starts = %d, want 1", got)
}
for range callers - 1 {
if err := <-errors; err != nil {
t.Fatalf("duplicate StartServicesLeaderElection() error = %v", err)
}
}
svcCtx.Cancel()
if err := <-errors; err != nil {
t.Fatalf("owner StartServicesLeaderElection() error = %v", err)
}
}
func TestStartServicesLeaderElectionRecreatesCancelledLease(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{})}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "recreate", Namespace: "default", UID: types.UID("recreate")}}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
oldLease := p.leaseMgr.Add(context.Background(), id)
oldLease.Cancel()
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
done := make(chan error, 1)
go func() { done <- p.StartServicesLeaderElection(svcCtx, service, nil, true) }()
waitForElectionRunner(t, runner.started)
if currentLease := p.leaseMgr.Get(id); currentLease == nil || currentLease == oldLease || currentLease.Ctx.Err() != nil {
t.Fatal("cancelled lease was not replaced for the live service")
}
svcCtx.Cancel()
if err := <-done; err != nil {
t.Fatalf("StartServicesLeaderElection() error = %v", err)
}
}
func TestStartServicesLeaderElectionRestartsAfterLeaseLoss(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{})}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "restart", Namespace: "default", UID: types.UID("restart")}}
metrics.ServiceElectionAttemptsTotal.DeleteLabelValues(service.Namespace, service.Name)
defer metrics.ServiceElectionAttemptsTotal.DeleteLabelValues(service.Namespace, service.Name)
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
done := make(chan error, 1)
go func() { done <- p.StartServicesLeaderElection(svcCtx, service, nil, true) }()
waitForElectionRunner(t, runner.started)
p.leaseMgr.Get(id).Cancel()
waitForCondition(t, func() bool { return runner.starts.Load() == 2 }, "replacement campaign after lease loss")
if got := testutil.ToFloat64(metrics.ServiceElectionAttemptsTotal.WithLabelValues(service.Namespace, service.Name)); got != 2 {
t.Fatalf("election attempts after lease loss = %v, want 2", got)
}
if currentLease := p.leaseMgr.Get(id); currentLease == nil || currentLease.Ctx.Err() != nil {
t.Fatal("live service did not recreate its lease after loss")
}
svcCtx.Cancel()
if err := <-done; err != nil {
t.Fatalf("StartServicesLeaderElection() error = %v", err)
}
}
func TestServiceElectionWaitGroupDrainsCampaignOnShutdown(t *testing.T) {
releaseStop := make(chan struct{})
runner := &electionTestRunner{started: make(chan struct{}), stopping: make(chan struct{}), releaseStop: releaseStop}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "shutdown", Namespace: "default", UID: types.UID("shutdown"),
}}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
var wg sync.WaitGroup
startDone := make(chan error, 1)
go func() {
startDone <- p.StartServicesLeaderElection(svcCtx, service, &wg, true)
}()
waitForElectionRunner(t, runner.started)
svcCtx.Cancel()
select {
case err := <-startDone:
if err != nil {
t.Fatalf("StartServicesLeaderElection() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Service election watcher did not stop after cancellation")
}
waitForElectionRunner(t, runner.stopping)
waitStarted := make(chan struct{})
waitDone := make(chan struct{})
go func() {
close(waitStarted)
wg.Wait()
close(waitDone)
}()
<-waitStarted
select {
case <-waitDone:
t.Fatal("Service WaitGroup completed while campaign shutdown was blocked")
case <-time.After(25 * time.Millisecond):
}
close(releaseStop)
select {
case <-waitDone:
case <-time.After(time.Second):
t.Fatal("Service WaitGroup did not complete after campaign shutdown")
}
}
func TestServiceElectionAttemptWaitsForReadiness(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{})}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "readiness", Namespace: "default", UID: types.UID("readiness")}}
metrics.ServiceElectionAttemptsTotal.DeleteLabelValues(service.Namespace, service.Name)
defer metrics.ServiceElectionAttemptsTotal.DeleteLabelValues(service.Namespace, service.Name)
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
done := make(chan error, 1)
go func() { done <- p.StartServicesLeaderElection(svcCtx, service, nil, true) }()
select {
case <-runner.started:
t.Fatal("campaign started before endpoint readiness")
case <-time.After(25 * time.Millisecond):
}
if got := testutil.ToFloat64(metrics.ServiceElectionAttemptsTotal.WithLabelValues(service.Namespace, service.Name)); got != 0 {
t.Fatalf("election attempts before readiness = %v, want 0", got)
}
svcCtx.SignalReadiness()
waitForElectionRunner(t, runner.started)
if got := testutil.ToFloat64(metrics.ServiceElectionAttemptsTotal.WithLabelValues(service.Namespace, service.Name)); got != 1 {
t.Fatalf("election attempts after readiness = %v, want 1", got)
}
svcCtx.Cancel()
if err := <-done; err != nil {
t.Fatalf("StartServicesLeaderElection() error = %v", err)
}
}
func TestSharedElectionDrainsBeforeRestartAfterAllMembersLoseReadiness(t *testing.T) {
releaseStop := make(chan struct{})
runner := &electionTestRunner{started: make(chan struct{}), stopping: make(chan struct{}), releaseStop: releaseStop}
p := &Processor{
config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run,
scheduleElectionRestart: func(restart func()) { restart() },
}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
firstService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", UID: types.UID("first"), Annotations: annotations,
}}
secondService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "second", Namespace: "default", UID: types.UID("second"), Annotations: annotations,
}}
firstCtx := servicecontext.New(context.Background())
secondCtx := servicecontext.New(context.Background())
p.svcMap.Store(firstService.UID, firstCtx)
p.svcMap.Store(secondService.UID, secondCtx)
firstCtx.SignalReadiness()
secondCtx.SignalReadiness()
firstDone := make(chan error, 1)
secondDone := make(chan error, 1)
go func() { firstDone <- p.StartServicesLeaderElection(firstCtx, firstService, nil, true) }()
go func() { secondDone <- p.StartServicesLeaderElection(secondCtx, secondService, nil, true) }()
waitForElectionRunner(t, runner.started)
namespace, name := lease.ServiceName(firstService)
waitForServiceElectionMembers(t, p, lease.NewID(p.config.LeaderElectionType, namespace, name), 2)
resetServiceReadiness(t, firstCtx)
resetServiceReadiness(t, secondCtx)
waitForElectionRunner(t, runner.stopping)
firstCtx.SignalReadiness()
secondCtx.SignalReadiness()
if runner.starts.Load() != 1 {
t.Fatalf("replacement campaign started before old campaign drained: starts = %d", runner.starts.Load())
}
close(releaseStop)
waitForCondition(t, func() bool { return runner.starts.Load() == 2 }, "replacement campaign after old campaign drain")
firstCtx.Cancel()
secondCtx.Cancel()
if err := <-firstDone; err != nil {
t.Fatalf("first service election error = %v", err)
}
if err := <-secondDone; err != nil {
t.Fatalf("second service election error = %v", err)
}
}
func TestSharedElectionDeletedCandidateNeverActivates(t *testing.T) {
releaseLeading := make(chan struct{})
runner := &electionTestRunner{started: make(chan struct{}), releaseLeading: releaseLeading}
var syncMutex sync.Mutex
syncCalls := map[types.UID]int{}
p := &Processor{
config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run,
serviceSync: func(_ context.Context, _ *servicecontext.Context, service *v1.Service, _ *sync.WaitGroup, _ bool) error {
syncMutex.Lock()
defer syncMutex.Unlock()
syncCalls[service.UID]++
return nil
},
}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
candidateService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "candidate", Namespace: "default", UID: types.UID("candidate"), Annotations: annotations,
}}
siblingService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "sibling", Namespace: "default", UID: types.UID("sibling"), Annotations: annotations,
}}
candidateCtx := servicecontext.New(context.Background())
siblingCtx := servicecontext.New(context.Background())
p.svcMap.Store(candidateService.UID, candidateCtx)
p.svcMap.Store(siblingService.UID, siblingCtx)
candidateCtx.SignalReadiness()
siblingCtx.SignalReadiness()
candidateDone := make(chan error, 1)
siblingDone := make(chan error, 1)
go func() { candidateDone <- p.StartServicesLeaderElection(candidateCtx, candidateService, nil, true) }()
go func() { siblingDone <- p.StartServicesLeaderElection(siblingCtx, siblingService, nil, true) }()
waitForElectionRunner(t, runner.started)
namespace, name := lease.ServiceName(candidateService)
election := waitForServiceElectionMembers(t, p, lease.NewID(p.config.LeaderElectionType, namespace, name), 2)
candidateCtx.Cancel()
if err := <-candidateDone; err != nil {
t.Fatalf("candidate service election error = %v", err)
}
close(releaseLeading)
waitForCondition(t, func() bool {
election.mutex.Lock()
defer election.mutex.Unlock()
return len(election.members) == 1 && election.members[siblingService.UID].active
}, "live sibling activation")
election.mutex.Lock()
_, candidateActive := election.members[candidateService.UID]
election.mutex.Unlock()
if candidateActive {
t.Fatal("deleted candidate remained eligible for activation")
}
syncMutex.Lock()
candidateSyncs := syncCalls[candidateService.UID]
siblingSyncs := syncCalls[siblingService.UID]
syncMutex.Unlock()
if candidateSyncs != 0 {
t.Fatalf("deleted candidate synchronized %d times, want 0", candidateSyncs)
}
if siblingSyncs != 1 {
t.Fatalf("live sibling synchronized %d times, want 1", siblingSyncs)
}
siblingCtx.Cancel()
if err := <-siblingDone; err != nil {
t.Fatalf("sibling service election error = %v", err)
}
}
func TestSharedElectionReadinessIsMemberLocal(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{})}
p := &Processor{config: &kubevip.Config{}, leaseMgr: lease.NewManager(), electionRun: runner.run}
annotations := map[string]string{kubevip.ServiceLease: "shared"}
firstService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "first", Namespace: "default", UID: types.UID("first"), Annotations: annotations,
}}
secondService := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "second", Namespace: "default", UID: types.UID("second"), Annotations: annotations,
}}
firstCtx := servicecontext.New(context.Background())
secondCtx := servicecontext.New(context.Background())
p.svcMap.Store(firstService.UID, firstCtx)
p.svcMap.Store(secondService.UID, secondCtx)
firstCtx.SignalReadiness()
secondCtx.SignalReadiness()
firstDone := make(chan error, 1)
secondDone := make(chan error, 1)
go func() { firstDone <- p.StartServicesLeaderElection(firstCtx, firstService, nil, true) }()
go func() { secondDone <- p.StartServicesLeaderElection(secondCtx, secondService, nil, true) }()
waitForElectionRunner(t, runner.started)
namespace, name := lease.ServiceName(firstService)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
waitForServiceElectionMembers(t, p, id, 2)
resetServiceReadiness(t, firstCtx)
waitForServiceElectionMembers(t, p, id, 1)
if runner.starts.Load() != 1 {
t.Fatalf("campaign starts after one member lost readiness = %d, want 1", runner.starts.Load())
}
firstCtx.SignalReadiness()
waitForServiceElectionMembers(t, p, id, 2)
if runner.starts.Load() != 1 {
t.Fatalf("campaign starts after one member recovered readiness = %d, want 1", runner.starts.Load())
}
firstCtx.Cancel()
secondCtx.Cancel()
if err := <-firstDone; err != nil {
t.Fatalf("first service election error = %v", err)
}
if err := <-secondDone; err != nil {
t.Fatalf("second service election error = %v", err)
}
}
func TestServiceOwnedCampaignSurvivesFinalServiceWhileControlPlaneRemains(t *testing.T) {
runner := &electionTestRunner{started: make(chan struct{}), stopping: make(chan struct{})}
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
electionRun: runner.run,
serviceSync: func(context.Context, *servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error { return nil },
}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "service", Namespace: "default", UID: types.UID("service"),
Annotations: map[string]string{kubevip.ServiceLease: "shared"},
}}
namespace, name := lease.ServiceName(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, svcCtx)
svcCtx.SignalReadiness()
var wg sync.WaitGroup
done := make(chan error, 1)
go func() {
done <- p.StartServicesLeaderElection(svcCtx, service, &wg, true)
}()
waitForElectionRunner(t, runner.started)
sharedLease := p.leaseMgr.Get(id)
controlPlaneToken := lease.ObjectName(id, "cp")
if claimed, _ := p.leaseMgr.Claim(id, controlPlaneToken); claimed != sharedLease {
t.Fatal("control plane did not join the Service-owned lease")
}
svcCtx.Cancel()
if err := <-done; err != nil {
t.Fatalf("Service election watcher returned an error: %v", err)
}
select {
case <-runner.stopping:
t.Fatal("final Service departure stopped a campaign still used by the control plane")
case <-time.After(20 * time.Millisecond):
}
if sharedLease.Ctx.Err() != nil || !sharedLease.Elected.Load() || p.leaseMgr.Get(id) != sharedLease {
t.Fatal("Service departure retired the control-plane campaign")
}
p.leaseMgr.Delete(id, controlPlaneToken, sharedLease)
wg.Wait()
select {
case <-runner.stopping:
default:
t.Fatal("campaign did not stop after its final control-plane member left")
}
}
type electionTestRunner struct {
started chan struct{}
startedOnce sync.Once
starts atomic.Int64
releaseLeading <-chan struct{}
stopping chan struct{}
stoppingOnce sync.Once
releaseStop <-chan struct{}
}
func (r *electionTestRunner) run(ctx context.Context, run *election.RunConfig, _ *kubevip.Config) error {
r.starts.Add(1)
r.startedOnce.Do(func() { close(r.started) })
if r.releaseLeading != nil {
<-r.releaseLeading
}
run.OnStartedLeading(ctx)
<-ctx.Done()
if r.stopping != nil {
r.stoppingOnce.Do(func() { close(r.stopping) })
}
if r.releaseStop != nil {
<-r.releaseStop
}
run.OnStoppedLeading()
return nil
}
func waitForElectionRunner(t *testing.T, started <-chan struct{}) {
t.Helper()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("election campaign did not start")
}
}
func waitForCondition(t *testing.T, condition func() bool, description string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for !condition() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", description)
}
time.Sleep(time.Millisecond)
}
}
func waitForServiceElectionMembers(t *testing.T, p *Processor, id lease.ID, want int) *serviceElection {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
p.electionsMutex.Lock()
election := p.elections[id.NamespacedName()]
p.electionsMutex.Unlock()
if election != nil {
election.mutex.Lock()
count := len(election.members)
election.mutex.Unlock()
if count == want {
return election
}
}
time.Sleep(time.Millisecond)
}
t.Fatalf("service election member count did not reach %d", want)
return nil
}

View File

@@ -2,16 +2,18 @@ package services
import (
"context"
"errors"
"fmt"
log "log/slog"
"net"
"reflect"
"sync"
"sync/atomic"
"time"
"github.com/kube-vip/kube-vip/pkg/arp"
"github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
@@ -25,22 +27,39 @@ import (
"github.com/kube-vip/kube-vip/pkg/vip"
"github.com/kube-vip/kube-vip/pkg/wireguard"
"github.com/prometheus/client_golang/prometheus"
coordinationv1 "k8s.io/api/coordination/v1"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/keymutex"
)
const concurrentServiceLocks = 128
var errServiceAddressPending = errors.New("service load-balancer address pending")
type Processor struct {
config *kubevip.Config
lbClassFilter func(svc *v1.Service, config *kubevip.Config) bool
svcMap sync.Map
// Keeps track of all running instances
// instancesMutex protects membership of ServiceInstances. Mutable fields on each
// instance are protected separately by serviceLocks, keyed by Instance.UID().
ServiceInstances []*instance.Instance
instancesMutex sync.RWMutex
serviceCleanupMu sync.Mutex
recoveryMu sync.Mutex
recovered bool
serviceLocks keymutex.KeyMutex
serviceLocksOnce sync.Once
electionsMutex sync.Mutex
elections map[string]*serviceElection
nextMemberToken atomic.Uint64
electionLoops sync.Map
mutex sync.Mutex
bgpServer *bgp.Server
clientSet *kubernetes.Clientset
@@ -54,7 +73,11 @@ type Processor struct {
// nodeLabelManager is the manager for the node labels
nodeLabelManager node.Labeler
electionMgr *election.Manager
electionMgr *election.Manager
electionRun func(context.Context, *election.RunConfig, *kubevip.Config) error
serviceSync func(context.Context, *servicecontext.Context, *v1.Service, *sync.WaitGroup, bool) error
scheduleElectionRestart func(func())
instanceFactory func(context.Context, *v1.Service, *sync.WaitGroup) (*instance.Instance, error)
// TunnelMgr manages multiple WireGuard tunnels (one per service VIP)
TunnelMgr *wireguard.TunnelManager
@@ -77,6 +100,8 @@ func NewServicesProcessor(config *kubevip.Config, bgpServer *bgp.Server,
config: config,
lbClassFilter: lbClassFilterFunc,
ServiceInstances: []*instance.Instance{},
serviceLocks: keymutex.NewHashed(concurrentServiceLocks),
elections: make(map[string]*serviceElection),
bgpServer: bgpServer,
clientSet: clientSet,
rwClientSet: rwClientSet,
@@ -90,18 +115,17 @@ func NewServicesProcessor(config *kubevip.Config, bgpServer *bgp.Server,
}
}
func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceFunc *Callback, forcedOnly bool,
func (p *Processor) Reconcile(ctx context.Context, event watch.Event, serviceFunc *Callback, forcedOnly bool,
wg *sync.WaitGroup, cancelWatcher context.CancelCauseFunc) error {
svc, ok := event.Object.(*v1.Service)
if !ok {
if !ok || svc == nil {
return fmt.Errorf("unable to parse Kubernetes services from API watcher")
}
timer := prometheus.NewTimer(metrics.ServiceReconcileDuration.WithLabelValues(svc.Namespace))
defer timer.ObserveDuration()
if forcedOnly && svc.Annotations[kubevip.ForcePerServiceElection] != "true" ||
!forcedOnly && svc.Annotations[kubevip.ForcePerServiceElection] == "true" {
if !serviceMatchesWatcher(svc, forcedOnly) {
return nil
}
@@ -141,42 +165,51 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
svc = s
}
svcInstance := instance.FindServiceInstance(svc, p.ServiceInstances)
var svcInstance *instance.Instance
var svcCtx *servicecontext.Context
shouldGarbageCollect := false
var err error
if err := func() error {
unlockService := p.lockService(svc.UID)
defer unlockService()
_, usesCommonLease := svc.Annotations[kubevip.ServiceLease]
if usesCommonLease && svc.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "invalid_config").Inc()
return fmt.Errorf("annotation %q cannot be used with service traffic policy other than %q, service %s/%s",
kubevip.ServiceLease, v1.ServiceExternalTrafficPolicyTypeCluster, svc.Namespace, svc.Name)
}
svcInstance = p.findServiceInstance(svc)
_, usesCommonLease := svc.Annotations[kubevip.ServiceLease]
if usesCommonLease && svc.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "invalid_config").Inc()
return fmt.Errorf("annotation %q cannot be used with service traffic policy other than %q, service %s/%s",
kubevip.ServiceLease, v1.ServiceExternalTrafficPolicyTypeCluster, svc.Namespace, svc.Name)
}
svcCtx, err := p.getServiceContext(svc.UID)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "service_context").Inc()
return fmt.Errorf("failed to get service context: %w", err)
svcCtx, err = p.getServiceContext(svc.UID)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "service_context").Inc()
return fmt.Errorf("failed to get service context: %w", err)
}
if event.Type == watch.Modified && svcInstance != nil {
shouldGarbageCollect = serviceChanged(svcInstance, svc)
}
return nil
}(); err != nil {
return err
}
if svcCtx != nil && svcCtx.Ctx.Err() != nil {
svcCtx, err = p.ensureServiceContext(ctx, svc)
if err != nil {
return fmt.Errorf("replace cancelled service context: %w", err)
}
}
svcCtx = p.dropCancelledServiceContext(svc.UID, svcCtx)
// The modified event should only be triggered if the service has been modified (i.e. moved somewhere else)
if event.Type == watch.Modified {
shouldGarbageCollect := false
if svcInstance != nil {
shouldGarbageCollect = serviceChanged(svcInstance, svc)
}
if shouldGarbageCollect {
for _, addr := range svcAddresses {
// log.Debugf("(svcs) Retrieving local addresses, to ensure that this modified address doesn't exist: %s", addr)
f, err := vip.GarbageCollect(p.config.Interface, addr, p.intfMgr)
if err != nil {
log.Error("(svcs) cleaning existing address error", "err", err)
}
if f {
log.Warn("(svcs) already found existing config", "address", addr, "adapter", p.config.Interface)
}
}
// This service has been modified, but it was also active.
if svcCtx != nil {
log.Warn("(svcs) The load balancer has changed, cancelling original load balancer")
oldService := svc
if svcInstance != nil && svcInstance.ServiceSnapshot != nil {
oldService = svcInstance.ServiceSnapshot
}
//Set it to inactive
svcCtx.Cancel()
@@ -184,12 +217,7 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "delete_service").Inc()
log.Error("(svc) unable to remove", "service", svc.UID)
}
// Retire the lease before the replacement context is built, so Add below
// cannot hand back an instance the pending cleanup is about to cancel.
// A lease shared with other services keeps their references and survives.
ns, name := lease.ServiceName(svc)
leaseID := lease.NewID(p.config.LeaderElectionType, ns, name)
p.leaseMgr.Delete(leaseID, lease.ServiceNamespacedName(svc), nil)
p.leaveServiceElectionForContext(svcCtx, oldService)
// Reset the the svcCtx when it was garbage collected
// As the next function will create a new context when nil
svcCtx = nil
@@ -198,38 +226,35 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
}
}
}
ips, hostnames := instance.FetchServiceAddresses(svc)
log.Debug("(svcs) has been added/modified with addresses", "service name", svc.Name, "ips", ips, "hostnames", hostnames)
if svcCtx == nil {
ns, name := lease.ServiceName(svc)
leaseID := lease.NewID(p.config.LeaderElectionType, ns, name)
p.leaseMgr.Add(ctx, leaseID)
// The service context is parented to the watcher, not to the lease: losing a
// lease must not tear the service down, it has to let the election restart.
svcCtx = servicecontext.New(ctx)
p.svcMap.Store(svc.UID, svcCtx)
if svcCtx == nil || svcCtx.Ctx.Err() != nil {
svcCtx, err = p.ensureServiceContext(ctx, svc)
if err != nil {
return fmt.Errorf("failed to get service context: %w", err)
}
}
if svcInstance == nil {
svcInstance, err = instance.NewInstance(svcCtx.Ctx, svc, p.config, p.intfMgr, p.arpMgr, p.routeMgr, p.nodeLabelManager, wg)
var instanceAdded bool
svcInstance, instanceAdded, err = p.admitServiceInstance(ctx, svc, wg)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "new_instance").Inc()
return fmt.Errorf("unable to create instance for service %s/%s", svc.Namespace, svc.Name)
return err
}
if instanceAdded {
p.updateActiveServicesMetric()
}
p.ServiceInstances = append(p.ServiceInstances, svcInstance)
p.updateActiveServicesMetric()
}
// this goroutine starts service handling function (with or without leaderelection)
if !svcCtx.IsWatchedLocked() {
if svcCtx.StartWatching() {
wg.Go(func() {
watchWg := sync.WaitGroup{}
defer func() {
// wait for the sub-goroutines and tag service as not watched
watchWg.Wait()
svcCtx.SetWatched(false)
svcCtx.StopWatching()
}()
watchWg.Go(func() {
@@ -266,9 +291,6 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
})
})
// tag service as watched
svcCtx.SetWatched(true)
}
if !p.config.EnableServicesElection {
@@ -278,90 +300,392 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
return nil
}
func (p *Processor) waitForAddress(ctx context.Context, svc *v1.Service) (*v1.Service, error) {
addressCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
ticker := time.NewTicker(time.Second)
// admitServiceInstance constructs and tracks a Service instance under its
// Service lock. Callers must not already hold that lock.
func (p *Processor) admitServiceInstance(ctx context.Context, svc *v1.Service, wg *sync.WaitGroup) (*instance.Instance, bool, error) {
unlockService := p.lockService(svc.UID)
defer unlockService()
for {
select {
case <-addressCtx.Done():
return nil, fmt.Errorf("failed to wait for the service LB address: %w", ctx.Err())
case <-ticker.C:
s, err := p.clientSet.CoreV1().Services(svc.Namespace).Get(addressCtx, svc.Name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get updated service data: %w", err)
}
addrs, hostnames := instance.FetchServiceAddresses(s)
if len(addrs) > 0 || len(hostnames) > 0 {
return s, nil
}
serviceInstance := p.findServiceInstance(svc)
if serviceInstance != nil {
return serviceInstance, false, nil
}
serviceInstance, err := p.createServiceInstance(ctx, svc, wg)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "new_instance").Inc()
return nil, false, fmt.Errorf("unable to create instance for service %s/%s: %w", svc.Namespace, svc.Name, err)
}
p.appendServiceInstance(serviceInstance)
return serviceInstance, true, nil
}
func (p *Processor) createServiceInstance(ctx context.Context, svc *v1.Service, wg *sync.WaitGroup) (*instance.Instance, error) {
if p.instanceFactory != nil {
return p.instanceFactory(ctx, svc, wg)
}
return instance.NewInstance(ctx, svc, p.config, p.intfMgr, p.arpMgr, p.routeMgr, p.nodeLabelManager, wg)
}
func (p *Processor) waitForAddress(ctx context.Context, svc *v1.Service) (*v1.Service, error) {
s, err := p.clientSet.CoreV1().Services(svc.Namespace).Get(ctx, svc.Name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get updated service data: %w", err)
}
addrs, hostnames := instance.FetchServiceAddresses(s)
if len(addrs) == 0 && len(hostnames) == 0 {
return nil, errServiceAddressPending
}
return s, nil
}
// RecoverAddresses removes tagged addresses that no longer belong to
// this node. Kubernetes lease holder identity is authoritative for per-Service
// election; modes without a per-Service lease retain Service VIPs conservatively.
func (p *Processor) RecoverAddresses(ctx context.Context) error {
p.recoveryMu.Lock()
defer p.recoveryMu.Unlock()
if p.recovered || p.clientSet == nil || p.config.RoutingProtocol < 4 {
return nil
}
services, err := p.clientSet.CoreV1().Services(p.config.ServiceNamespace).List(ctx, metav1.ListOptions{})
if err != nil {
return fmt.Errorf("list Services for address recovery: %w", err)
}
holders := make(map[string]string)
retainedVIPs := make(map[string]struct{})
if p.config.LeaderElectionType != "etcd" {
if err := p.retainAnnotatedLeaseVIPs(ctx, holders, retainedVIPs); err != nil {
return err
}
}
for index := range services.Items {
service := &services.Items[index]
if !p.serviceOwnsRecoverableVIP(service) {
continue
}
retain, err := p.serviceAddressRetained(ctx, service, holders)
if err != nil {
return err
}
if !retain {
continue
}
for _, address := range serviceVIPAddresses(service) {
retainedVIPs[address] = struct{}{}
}
}
canClean, err := p.retainControlPlaneVIPs(ctx, holders, retainedVIPs)
if err != nil {
return err
}
if !canClean {
return nil
}
retained, err := vip.RetainedKubeVIPAddressKeys(p.config.RoutingProtocol, retainedVIPs)
if err != nil {
return fmt.Errorf("find retained kube-vip addresses: %w", err)
}
removed, err := vip.CleanupKubeVIPAddresses(p.config.RoutingProtocol, retained)
if err != nil {
return fmt.Errorf("remove orphaned kube-vip addresses: %w", err)
}
p.recovered = true
if removed != 0 {
log.Info("removed orphaned kube-vip addresses", "count", removed)
}
return nil
}
func (p *Processor) retainAnnotatedLeaseVIPs(ctx context.Context, holders map[string]string,
retainedVIPs map[string]struct{}) error {
namespace := v1.NamespaceAll
if p.config.ServiceNamespace != "" {
namespace = p.config.ServiceNamespace
}
leaseList, err := p.clientSet.CoordinationV1().Leases(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return fmt.Errorf("list Leases for address recovery: %w", err)
}
for index := range leaseList.Items {
resource := &leaseList.Items[index]
holder := ""
if resource.Spec.HolderIdentity != nil {
holder = *resource.Spec.HolderIdentity
}
holders[resource.Namespace+"/"+resource.Name] = holder
encoded := resource.Annotations[kubevip.LeaseVIPs]
if encoded == "" || holder != p.config.NodeName || !leaseOwnershipCurrent(resource, time.Now()) {
continue
}
metadata, err := kubevip.ParseLeaseVIPs(encoded)
if err != nil {
return fmt.Errorf("parse Lease %s/%s VIP ownership: %w", resource.Namespace, resource.Name, err)
}
if metadata.IFAProto != p.config.RoutingProtocol {
continue
}
for _, claimedVIP := range metadata.VIPs {
retainedVIPs[claimedVIP.Value] = struct{}{}
}
}
return nil
}
func leaseOwnershipCurrent(resource *coordinationv1.Lease, now time.Time) bool {
if resource.Spec.RenewTime == nil || resource.Spec.LeaseDurationSeconds == nil {
return true
}
expires := resource.Spec.RenewTime.Add(time.Duration(*resource.Spec.LeaseDurationSeconds) * time.Second)
return now.Before(expires)
}
func (p *Processor) serviceOwnsRecoverableVIP(service *v1.Service) bool {
classFilter := p.lbClassFilter
if classFilter == nil {
classFilter = lbClassFilter
}
return service != nil && service.Spec.Type == v1.ServiceTypeLoadBalancer &&
service.Annotations[kubevip.LoadbalancerIgnore] != "true" &&
!classFilter(service, p.config)
}
func (p *Processor) serviceAddressRetained(ctx context.Context, service *v1.Service, holders map[string]string) (bool, error) {
if p.config.LeaderElectionType == "etcd" {
return true, nil
}
if !p.config.EnableServicesElection && !p.usesGlobalServiceElection() {
return true, nil
}
namespace, name := p.serviceRecoveryLease(service)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
local, err := p.isLocalLeaseHolder(ctx, id, holders)
if err != nil {
return false, fmt.Errorf("get Service lease %q for address recovery: %w", id.NamespacedName(), err)
}
return local, nil
}
func (p *Processor) usesGlobalServiceElection() bool {
return p.config.EnableARP || p.config.EnableWireguard ||
((p.config.EnableBGP || p.config.EnableRoutingTable) && p.config.EnableLeaderElection)
}
func (p *Processor) serviceRecoveryLease(service *v1.Service) (string, string) {
if p.config.EnableServicesElection {
return lease.ServiceName(service)
}
return lease.NamespaceName(p.config.ServicesLeaseName, p.config)
}
func (p *Processor) retainControlPlaneVIPs(ctx context.Context, holders map[string]string, retainedVIPs map[string]struct{}) (bool, error) {
if !p.config.EnableControlPlane {
return true, nil
}
addresses, known := configuredVIPAddresses(p.config)
if !known {
// A hostname-backed control-plane VIP may currently resolve to an
// address that is not present in the static config. Do not sweep any
// tagged address until that ownership can be determined safely.
log.Warn("skipping address recovery for hostname-backed control-plane VIP")
return false, nil
}
// etcd lease holders are not visible through the Kubernetes API, and without
// election every control-plane node advertises the VIP: both retain locally.
if p.config.LeaderElectionType == "etcd" || !p.config.EnableLeaderElection {
for _, address := range addresses {
retainedVIPs[address] = struct{}{}
}
return true, nil
}
namespace, name := lease.NamespaceName(p.config.LeaseName, p.config)
id := lease.NewID(p.config.LeaderElectionType, namespace, name)
local, err := p.isLocalLeaseHolder(ctx, id, holders)
if err != nil {
return false, fmt.Errorf("get control-plane lease for address recovery: %w", err)
}
if !local {
return true, nil
}
for _, address := range addresses {
retainedVIPs[address] = struct{}{}
}
return true, nil
}
func configuredVIPAddresses(config *kubevip.Config) ([]string, bool) {
configured := config.VIP
if config.Address != "" {
configured = config.Address
}
addresses := make([]string, 0)
for _, value := range vip.Split(configured) {
address := net.ParseIP(utils.StripCIDR(value))
if address == nil {
return nil, false
}
addresses = append(addresses, address.String())
}
return addresses, true
}
func (p *Processor) isLocalLeaseHolder(ctx context.Context, id lease.ID, holders map[string]string) (bool, error) {
holder, err := p.kubernetesLeaseHolder(ctx, id, holders)
if err != nil {
return false, err
}
return holder == p.config.NodeName, nil
}
func (p *Processor) kubernetesLeaseHolder(ctx context.Context, id lease.ID, holders map[string]string) (string, error) {
key := id.NamespacedName()
if holder, found := holders[key]; found {
return holder, nil
}
resource, err := p.clientSet.CoordinationV1().Leases(id.Namespace()).Get(ctx, id.Name(), metav1.GetOptions{})
if apierrors.IsNotFound(err) {
holders[key] = ""
return "", nil
}
if err != nil {
return "", err
}
holder := ""
if resource.Spec.HolderIdentity != nil {
holder = *resource.Spec.HolderIdentity
}
holders[key] = holder
return holder, nil
}
func serviceVIPAddresses(service *v1.Service) []string {
addresses, _ := instance.FetchServiceAddresses(service)
ingress, _ := instance.FetchLoadBalancerIngress(service)
addresses = append(addresses, ingress...)
return addresses
}
// ElectionVIPs returns configured Service VIPs in stable Service creation order.
func (p *Processor) ElectionVIPs(ctx context.Context) ([]string, error) {
if p == nil || p.clientSet == nil {
return nil, nil
}
serviceList, err := p.clientSet.CoreV1().Services(p.config.ServiceNamespace).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("list Services for election VIP metadata: %w", err)
}
services := make([]*v1.Service, 0, len(serviceList.Items))
for index := range serviceList.Items {
service := &serviceList.Items[index]
if p.serviceOwnsRecoverableVIP(service) {
services = append(services, service)
}
}
return orderedServiceVIPs(services), nil
}
func (p *Processor) Delete(event watch.Event, forcedOnly bool) error {
svc, ok := event.Object.(*v1.Service)
if !ok {
if !ok || svc == nil {
return fmt.Errorf("(svcs) unable to parse Kubernetes services from API watcher")
}
if forcedOnly && svc.Annotations[kubevip.ForcePerServiceElection] != "true" ||
!forcedOnly && svc.Annotations[kubevip.ForcePerServiceElection] == "true" {
if !serviceMatchesWatcher(svc, forcedOnly) {
return nil
}
return p.deleteTrackedService(svc)
}
func serviceMatchesWatcher(svc *v1.Service, forcedOnly bool) bool {
forced := svc.Annotations[kubevip.ForcePerServiceElection] == "true"
return forcedOnly == forced
}
func (p *Processor) deleteTrackedService(svc *v1.Service) error {
svcCtx, err := p.getServiceContext(svc.UID)
svcCtx, cleanupCtx, err := p.retireServiceContext(svc)
if err != nil {
return fmt.Errorf("(svcs) unable to get context: %w", err)
return err
}
if err := p.deleteService(cleanupCtx, svc.UID, svcCtx); err != nil {
return fmt.Errorf("delete service %s/%s: %w", svc.Namespace, svc.Name, err)
}
if svcCtx != nil {
// If no leader election is enabled, delete routes here
if !p.config.EnableLeaderElection && !p.config.EnableServicesElection &&
p.config.EnableRoutingTable && svcCtx.HasConfiguredNetworks() {
if errs := endpoints.ClearRoutes(svc, &p.ServiceInstances, p.routeMgr); len(errs) == 0 {
svcCtx.ConfiguredNetworks.Clear()
}
}
if !p.config.EnableServicesElection {
// If this is an active service then and additional leaderElection will handle stopping
err = p.deleteService(svcCtx.Ctx, svc.UID)
if err != nil {
log.Error(err.Error())
}
}
// Calls the cancel function of the context
log.Warn("(svcs) The load balancer was deleted, cancelling context", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
svcCtx.Cancel()
p.svcMap.Delete(svc.UID)
// Drop the per-service election series so a recreated service starts clean.
metrics.ServiceElectionLoops.DeleteLabelValues(svc.Namespace, svc.Name)
p.updateActiveServicesMetric()
log.Info("(svcs) deleted", "service name", svc.Name, "namespace", svc.Namespace)
p.svcMap.CompareAndDelete(svc.UID, svcCtx)
}
// Drop the per-service election series so a recreated service starts clean.
metrics.ServiceElectionLoops.DeleteLabelValues(svc.Namespace, svc.Name)
p.updateActiveServicesMetric()
log.Info("(svcs) deleted", "service name", svc.Name, "namespace", svc.Namespace)
return nil
}
func (p *Processor) Stop() {
p.mutex.Lock()
defer p.mutex.Unlock()
// retireServiceContext preemptively cancels the published Service context before
// acquiring the Service lock, then rechecks ownership under the lock.
func (p *Processor) retireServiceContext(svc *v1.Service) (*servicecontext.Context, context.Context, error) {
// Cancel before locking so in-flight Service work can release the lock.
contextBeforeLock, err := p.cancelPublishedServiceContext(svc.UID)
if err != nil {
return nil, nil, fmt.Errorf("(svcs) unable to get context: %w", err)
}
for _, instance := range p.ServiceInstances {
for _, cluster := range instance.Clusters {
cluster.Stop()
unlockService := p.lockService(svc.UID)
defer unlockService()
// A replacement context may have been published while waiting for the lock.
currentContext, err := p.getServiceContext(svc.UID)
if err != nil {
return nil, nil, fmt.Errorf("(svcs) unable to get context: %w", err)
}
cleanupCtx := context.Background()
if currentContext != nil {
log.Warn("(svcs) The load balancer was deleted, cancelling context", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
if currentContext != contextBeforeLock {
currentContext.Cancel()
}
p.leaveServiceElectionForContext(currentContext, svc)
cleanupCtx = context.WithoutCancel(currentContext.Ctx)
}
return currentContext, cleanupCtx, nil
}
func (p *Processor) cancelPublishedServiceContext(uid types.UID) (*servicecontext.Context, error) {
svcCtx, err := p.getServiceContext(uid)
if err != nil {
return nil, err
}
if svcCtx != nil {
svcCtx.Cancel()
}
return svcCtx, nil
}
// Stop acquires each instance's Service lock while stopping its workers and
// marking it for reconfiguration.
func (p *Processor) Stop() {
p.svcMap.Range(func(_, value any) bool {
if svcCtx, ok := value.(*servicecontext.Context); ok {
svcCtx.Cancel()
}
return true
})
for _, instance := range p.serviceInstances() {
unlockService := p.lockService(instance.UID())
for _, cluster := range instance.Clusters {
cluster.StopAndWait()
}
instance.AddCalled = false
unlockService()
}
}
// getServiceContext performs one concurrency-safe svcMap lookup. Callers that
// combine the result with other state changes must hold the Service lock for uid.
func (p *Processor) getServiceContext(uid types.UID) (*servicecontext.Context, error) {
svcCtx, ok := p.svcMap.Load(uid)
if !ok {
@@ -374,28 +698,69 @@ func (p *Processor) getServiceContext(uid types.UID) (*servicecontext.Context, e
return ctx, nil
}
// ensureServiceContext returns the current usable context or creates one. It
// acquires the Service lock for svc.UID; callers must not already hold it.
func (p *Processor) ensureServiceContext(ctx context.Context, svc *v1.Service) (*servicecontext.Context, error) {
for {
observed, err := p.getServiceContext(svc.UID)
if err != nil {
return nil, err
}
if observed != nil && observed.Ctx.Err() != nil {
if err := observed.WaitForWatchingStopped(ctx); err != nil {
return nil, err
}
}
current, retry, err := p.ensureServiceContextLocked(ctx, svc, observed)
if err != nil {
return nil, err
}
if retry {
continue
}
return current, nil
}
}
func (p *Processor) ensureServiceContextLocked(ctx context.Context, svc *v1.Service,
observed *servicecontext.Context) (*servicecontext.Context, bool, error) {
unlockService := p.lockService(svc.UID)
defer unlockService()
current, err := p.getServiceContext(svc.UID)
if err != nil {
return nil, false, err
}
if current != observed {
return nil, true, nil
}
current = p.dropCancelledServiceContext(svc, current)
if current == nil {
current = servicecontext.New(ctx)
p.svcMap.Store(svc.UID, current)
}
return current, false, nil
}
// dropCancelledServiceContext discards a service context whose context has already been
// cancelled, removing it from svcMap and returning nil so that callers create a fresh one.
//
// This matters because the in-memory lease and the service context are removed independently.
// The cleanup goroutine started by StartServicesLeaderElection calls leaseMgr.Delete once
// svcCtx.Ctx is done, and Manager.Delete drops the lease entirely when its last object goes
// away. Several paths cancel the service context without also removing it from svcMap - for
// example the deferred close(stopChan) in watchEndpoint, and the utils.PanicError branch in
// AddOrModify.
//
// If such a cancelled context were reused, AddOrModify would skip its `if svcCtx == nil`
// branch and therefore never call leaseMgr.Add again, so StartServicesLeaderElection would
// fail with "no existing lease found" on every subsequent event and the VIP would never be
// advertised again.
func (p *Processor) dropCancelledServiceContext(uid types.UID, svcCtx *servicecontext.Context) *servicecontext.Context {
// Callers wait for the old watcher lifecycle before invoking this function.
// The caller must hold the Service lock for svc.UID.
func (p *Processor) dropCancelledServiceContext(svc *v1.Service, svcCtx *servicecontext.Context) *servicecontext.Context {
if svcCtx == nil || svcCtx.Ctx.Err() == nil {
return svcCtx
}
p.svcMap.Delete(uid)
if serviceInstance := p.findServiceInstance(svc); serviceInstance != nil {
serviceInstance.AddCalled = false
}
p.svcMap.CompareAndDelete(svc.UID, svcCtx)
return nil
}
// serviceChanged reads the tracked instance snapshot. The caller must hold the
// Service lock for i.UID().
func serviceChanged(i *instance.Instance, svc *v1.Service) bool {
svcAddresses, svcHostnames := instance.FetchServiceAddresses(svc)
originalServiceAddresses, originalServiceHostnames := instance.FetchServiceAddresses(i.ServiceSnapshot)
@@ -408,22 +773,116 @@ func serviceChanged(i *instance.Instance, svc *v1.Service) bool {
svc.Spec.ExternalTrafficPolicy != i.ServiceSnapshot.Spec.ExternalTrafficPolicy ||
// IP stack configuration changed
!reflect.DeepEqual(svc.Spec.IPFamilies, i.ServiceSnapshot.Spec.IPFamilies) ||
*svc.Spec.IPFamilyPolicy != *i.ServiceSnapshot.Spec.IPFamilyPolicy ||
!ipFamilyPolicyEqual(svc.Spec.IPFamilyPolicy, i.ServiceSnapshot.Spec.IPFamilyPolicy) ||
// DDNS was disabled/enabled
svc.Annotations[kubevip.ServiceDDNS] != i.ServiceSnapshot.Annotations[kubevip.ServiceDDNS] ||
// lease name was changed
svc.Annotations[kubevip.ServiceLease] != i.ServiceSnapshot.Annotations[kubevip.ServiceLease]
}
func ipFamilyPolicyEqual(first, second *v1.IPFamilyPolicy) bool {
if first == nil || second == nil {
return first == second
}
return *first == *second
}
// updateActiveServicesMetric acquires each instance's Service lock before
// reading its snapshot.
func (p *Processor) updateActiveServicesMetric() {
counts := map[string]int{}
for _, inst := range p.ServiceInstances {
for _, inst := range p.serviceInstances() {
unlockService := p.lockService(inst.UID())
if inst.ServiceSnapshot != nil {
counts[inst.ServiceSnapshot.Namespace]++
}
unlockService()
}
metrics.ActiveServices.Reset()
for ns, count := range counts {
metrics.ActiveServices.WithLabelValues(ns).Set(float64(count))
}
}
// findServiceInstance protects the collection lookup only. The caller must hold
// the Service lock before accessing mutable fields on the returned instance.
func (p *Processor) findServiceInstance(service *v1.Service) *instance.Instance {
p.instancesMutex.RLock()
defer p.instancesMutex.RUnlock()
return instance.FindServiceInstance(service, p.ServiceInstances)
}
// serviceInstances returns a stable copy of the collection. The caller must hold
// each instance's Service lock before accessing its mutable fields.
func (p *Processor) serviceInstances() []*instance.Instance {
p.instancesMutex.RLock()
defer p.instancesMutex.RUnlock()
return append([]*instance.Instance(nil), p.ServiceInstances...)
}
// ServiceSnapshots returns stable copies for external observers such as
// diagnostics. It acquires each instance's Service lock while copying.
func (p *Processor) ServiceSnapshots() []*v1.Service {
instances := p.serviceInstances()
snapshots := make([]*v1.Service, 0, len(instances))
for _, inst := range instances {
if inst == nil {
continue
}
unlockService := p.lockService(inst.UID())
if inst.ServiceSnapshot != nil {
snapshots = append(snapshots, inst.ServiceSnapshot.DeepCopy())
}
unlockService()
}
return snapshots
}
// appendServiceInstance adds inst to the tracked collection. The caller must
// hold the Service lock for inst.UID() to serialize logical membership changes;
// instancesMutex protects only the slice mutation.
func (p *Processor) appendServiceInstance(inst *instance.Instance) {
p.instancesMutex.Lock()
defer p.instancesMutex.Unlock()
p.ServiceInstances = append(p.ServiceInstances, inst)
}
// detachServiceInstance removes the tracked instance for uid. The caller must
// hold the Service lock for uid to serialize logical membership changes;
// instancesMutex protects only the slice mutation.
func (p *Processor) detachServiceInstance(uid types.UID) (*instance.Instance, []*instance.Instance) {
p.instancesMutex.Lock()
defer p.instancesMutex.Unlock()
remaining := make([]*instance.Instance, 0, len(p.ServiceInstances))
var found *instance.Instance
for _, inst := range p.ServiceInstances {
if inst != nil && inst.UID() == uid {
found = inst
continue
}
remaining = append(remaining, inst)
}
if found != nil {
p.ServiceInstances = remaining
return found, append([]*instance.Instance(nil), remaining...)
}
return nil, append([]*instance.Instance(nil), remaining...)
}
// lockService serializes mutable state for one Service UID. The returned unlock
// function must be called exactly once; the lock is not reentrant.
func (p *Processor) lockService(uid types.UID) func() {
p.serviceLocksOnce.Do(func() {
if p.serviceLocks == nil {
p.serviceLocks = keymutex.NewHashed(concurrentServiceLocks)
}
})
key := string(uid)
p.serviceLocks.LockKey(key)
return func() {
if err := p.serviceLocks.UnlockKey(key); err != nil {
log.Error("failed to unlock service reconciliation", "uid", uid, "err", err)
}
}
}

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
@@ -46,13 +47,13 @@ func TestAddOrModifyStopsTrackedServiceWhenTypeChanges(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
ServiceInstances: []*instance.Instance{{ServiceUID: uid, ServiceSnapshot: tracked}},
ServiceInstances: []*instance.Instance{{ServiceUID: tracked.UID, ServiceSnapshot: tracked}},
}
svcCtx := servicecontext.New(context.Background())
p.svcMap.Store(uid, svcCtx)
if err := p.AddOrModify(context.Background(), watch.Event{Type: watch.Modified, Object: modified}, nil, false, nil, nil); err != nil {
t.Fatalf("AddOrModify returned error: %v", err)
if err := p.Reconcile(context.Background(), watch.Event{Type: watch.Modified, Object: modified}, nil, false, nil, nil); err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
if svcCtx.Ctx.Err() == nil {
@@ -90,6 +91,7 @@ func TestDropCancelledServiceContext(t *testing.T) {
}
uid := types.UID("service-uid")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: uid}}
t.Run("cancelled context is dropped and removed from svcMap", func(t *testing.T) {
p := newProcessor()
@@ -97,14 +99,22 @@ func TestDropCancelledServiceContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
svcCtx := servicecontext.New(ctx)
p.svcMap.Store(uid, svcCtx)
serviceInstance := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service, AddCalled: true}
p.ServiceInstances = []*instance.Instance{serviceInstance}
cancel()
if got := p.dropCancelledServiceContext(uid, svcCtx); got != nil {
if got := p.dropCancelledServiceContext(service, svcCtx); got != nil {
t.Fatalf("expected a cancelled service context to be dropped, got %v", got)
}
if _, ok := p.svcMap.Load(uid); ok {
t.Fatal("expected the cancelled service context to be removed from svcMap")
}
if serviceInstance.AddCalled {
t.Fatal("cancelled context left the Service marked as configured")
}
if action := p.getServiceInstanceAction(service); action != ActionAdd {
t.Fatalf("action after dropping cancelled context = %q, want %q", action, ActionAdd)
}
})
t.Run("live context is kept", func(t *testing.T) {
@@ -115,7 +125,7 @@ func TestDropCancelledServiceContext(t *testing.T) {
svcCtx := servicecontext.New(ctx)
p.svcMap.Store(uid, svcCtx)
if got := p.dropCancelledServiceContext(uid, svcCtx); got != svcCtx {
if got := p.dropCancelledServiceContext(service, svcCtx); got != svcCtx {
t.Fatalf("expected a live service context to be kept, got %v", got)
}
if _, ok := p.svcMap.Load(uid); !ok {
@@ -125,12 +135,53 @@ func TestDropCancelledServiceContext(t *testing.T) {
t.Run("nil context is a no-op", func(t *testing.T) {
p := newProcessor()
if got := p.dropCancelledServiceContext(uid, nil); got != nil {
if got := p.dropCancelledServiceContext(service, nil); got != nil {
t.Fatalf("expected nil to be returned for a nil service context, got %v", got)
}
})
}
func TestEnsureServiceContextWaitsForOldWatcherCleanup(t *testing.T) {
p := &Processor{config: &kubevip.Config{}}
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default", UID: "service"}}
oldContext := servicecontext.New(context.Background())
if !oldContext.StartWatching() {
t.Fatal("old watcher ownership was not acquired")
}
p.svcMap.Store(service.UID, oldContext)
oldContext.Cancel()
result := make(chan *servicecontext.Context, 1)
errs := make(chan error, 1)
go func() {
current, err := p.ensureServiceContext(context.Background(), service)
if err != nil {
errs <- err
return
}
result <- current
}()
select {
case <-result:
t.Fatal("replacement context was created before old watcher cleanup")
case err := <-errs:
t.Fatalf("ensureServiceContext() error = %v", err)
case <-time.After(20 * time.Millisecond):
}
oldContext.StopWatching()
select {
case current := <-result:
if current == oldContext || current.Ctx.Err() != nil {
t.Fatal("ensureServiceContext did not create a live replacement")
}
case err := <-errs:
t.Fatalf("ensureServiceContext() error = %v", err)
case <-time.After(time.Second):
t.Fatal("replacement context was not created after old watcher cleanup")
}
}
// TestDropCancelledServiceContextAllowsLeaseRecreation shows the consequence of the fix: once the
// cancelled context has been dropped, the caller takes the `svcCtx == nil` branch and a lease is
// created again, so StartServicesLeaderElection no longer fails with "no existing lease found".
@@ -162,7 +213,7 @@ func TestDropCancelledServiceContextAllowsLeaseRecreation(t *testing.T) {
t.Fatal("precondition failed: the lease manager should not hold a lease yet")
}
if got := p.dropCancelledServiceContext(svc.UID, svcCtx); got != nil {
if got := p.dropCancelledServiceContext(svc, svcCtx); got != nil {
t.Fatalf("expected the stale service context to be dropped, got %v", got)
}
@@ -196,9 +247,10 @@ func TestOnStoppedLeadingDoesNotDeleteReplacementContext(t *testing.T) {
leaseNamespace, serviceLease := lease.ServiceName(service)
svcLease := p.leaseMgr.Add(context.Background(), lease.NewID(p.config.LeaderElectionType, leaseNamespace, serviceLease))
member := &serviceElectionMember{service: service, serviceContext: oldCtx}
if err := p.onStoppedLeading(oldCtx, svcLease, service); err != nil {
t.Fatalf("onStoppedLeading returned an error: %v", err)
if err := p.onStoppedLeadingMember(member, svcLease); err != nil {
t.Fatalf("onStoppedLeadingMember returned an error: %v", err)
}
if got, err := p.getServiceContext(service.UID); err != nil || got != replacementCtx {
t.Fatalf("replacement context was changed: got %v, err %v", got, err)

View File

@@ -0,0 +1,602 @@
package services
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
v1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/utils/keymutex"
)
type testLabeler struct {
addErr error
addErrors []error
addCalls int
removeErr error
}
func (l *testLabeler) AddLabel(map[string]string) error {
l.addCalls++
if len(l.addErrors) > 0 {
err := l.addErrors[0]
l.addErrors = l.addErrors[1:]
return err
}
return l.addErr
}
func (l *testLabeler) RemoveLabel(map[string]string) error {
return l.removeErr
}
func TestServiceLocksAreScopedByUID(t *testing.T) {
processor := &Processor{}
t.Run("different Services proceed concurrently", func(t *testing.T) {
unlockFirst := processor.lockService(types.UID("service-a"))
acquired := make(chan struct{})
go func() {
unlockSecond := processor.lockService(types.UID("service-b"))
close(acquired)
unlockSecond()
}()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("different Service UID was blocked by another Service lock")
}
unlockFirst()
})
t.Run("same Service remains serialized", func(t *testing.T) {
uid := types.UID("service-a")
unlockFirst := processor.lockService(uid)
acquired := make(chan struct{})
go func() {
unlockSecond := processor.lockService(uid)
close(acquired)
unlockSecond()
}()
select {
case <-acquired:
t.Fatal("same Service UID acquired the lock concurrently")
case <-time.After(50 * time.Millisecond):
}
unlockFirst()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("same Service UID remained blocked after unlock")
}
})
}
func TestAdmissionDoesNotSerializeUnrelatedInstanceConstruction(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
processor := &Processor{
config: &kubevip.Config{},
serviceLocks: keymutex.NewHashed(concurrentServiceLocks),
instanceFactory: func(_ context.Context, svc *v1.Service, _ *sync.WaitGroup) (*instance.Instance, error) {
if svc.Name == "slow" {
close(started)
<-release
}
return &instance.Instance{ServiceUID: svc.UID, ServiceSnapshot: svc.DeepCopy()}, nil
},
}
slow := admissionTestService("slow", "192.0.2.10")
fast := admissionTestService("fast", "192.0.2.11")
slowDone := make(chan error, 1)
go func() {
_, _, err := processor.admitServiceInstance(context.Background(), slow, &sync.WaitGroup{})
slowDone <- err
}()
<-started
fastDone := make(chan error, 1)
go func() {
_, _, err := processor.admitServiceInstance(context.Background(), fast, &sync.WaitGroup{})
fastDone <- err
}()
select {
case err := <-fastDone:
if err != nil {
t.Fatalf("unrelated admission error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("unrelated admission waited for slow instance construction")
}
releaseOnce.Do(func() { close(release) })
if err := <-slowDone; err != nil {
t.Fatalf("slow admission error = %v", err)
}
}
func admissionTestService(name, address string) *v1.Service {
return &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", UID: types.UID(name)},
Spec: v1.ServiceSpec{
LoadBalancerIP: address,
ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,
},
}
}
func TestReconcileAndDeleteRejectTypedNilService(t *testing.T) {
var service *v1.Service
event := watch.Event{Object: service}
processor := &Processor{}
if err := processor.Reconcile(context.Background(), event, nil, false, nil, nil); err == nil {
t.Fatal("Reconcile() accepted a typed-nil Service")
}
if err := processor.Delete(event, false); err == nil {
t.Fatal("Delete() accepted a typed-nil Service")
}
}
func TestServiceChangedHandlesNilIPFamilyPolicy(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: "service"}}
instance := &instance.Instance{ServiceSnapshot: service.DeepCopy()}
if serviceChanged(instance, service) {
t.Fatal("identical Services with nil IP family policies were considered changed")
}
policy := v1.IPFamilyPolicySingleStack
service.Spec.IPFamilyPolicy = &policy
if !serviceChanged(instance, service) {
t.Fatal("Service IP family policy change was not detected")
}
}
func TestDeleteServiceCleansUpAfterContextRemoval(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: uid, Name: "service-a", Namespace: "default"}}
processor := &Processor{
config: &kubevip.Config{EnableServicesElection: true},
ServiceInstances: []*instance.Instance{{ServiceUID: uid, ServiceSnapshot: service}},
}
if err := processor.deleteService(context.Background(), uid, servicecontext.New(context.Background())); err != nil {
t.Fatalf("deleteService() error = %v", err)
}
if got := processor.findServiceInstance(service); got != nil {
t.Fatal("deleted Service instance remained tracked after leader cleanup")
}
}
func TestRetireServiceContextCancelsBeforeServiceLockIsAvailable(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: uid, Name: "service-a", Namespace: "default"}}
svcCtx := servicecontext.New(context.Background())
processor := &Processor{config: &kubevip.Config{}}
processor.svcMap.Store(uid, svcCtx)
unlockService := processor.lockService(uid)
done := make(chan error, 1)
go func() {
_, _, err := processor.retireServiceContext(service)
done <- err
}()
select {
case <-svcCtx.Ctx.Done():
case <-time.After(time.Second):
unlockService()
t.Fatal("retireServiceContext waited for the Service lock before cancelling")
}
unlockService()
select {
case err := <-done:
if err != nil {
t.Fatalf("retireServiceContext() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("retireServiceContext did not finish after the Service lock was released")
}
}
func TestDeleteServiceIsIdempotentWhenInstanceIsMissing(t *testing.T) {
processor := &Processor{}
if err := processor.deleteService(context.Background(), types.UID("missing-service")); err != nil {
t.Fatalf("deleteService() error = %v, want nil", err)
}
}
func TestAddServiceMarksPreTrackedInstanceAdded(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
serviceInstance := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service}
processor := &Processor{
config: &kubevip.Config{DisableServiceUpdates: true, EnableServicesElection: true},
ServiceInstances: []*instance.Instance{serviceInstance},
nodeLabelManager: &testLabeler{},
}
if err := processor.addService(context.Background(), service, &sync.WaitGroup{}); err != nil {
t.Fatalf("addService() error = %v", err)
}
if !serviceInstance.AddCalled {
t.Fatal("pre-tracked Service instance was not marked added")
}
if err := processor.addService(context.Background(), service, &sync.WaitGroup{}); err != nil {
t.Fatalf("second addService() error = %v", err)
}
}
func TestPrepareServiceInstanceRejectsCancelledContext(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: types.UID("cancelled-service"), Name: "cancelled-service", Namespace: "default",
}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
processor := &Processor{config: &kubevip.Config{}}
created, err := processor.prepareServiceInstance(ctx, service, &sync.WaitGroup{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("prepareServiceInstance() error = %v, want context cancellation", err)
}
if created != nil {
t.Fatal("prepareServiceInstance() returned an instance for a cancelled context")
}
if got := processor.findServiceInstance(service); got != nil {
t.Fatal("cancelled Service context left a tracked instance")
}
}
func TestPrepareServiceInstanceUsesSharedFactory(t *testing.T) {
service := admissionTestService("service", "192.0.2.10")
called := false
processor := &Processor{
config: &kubevip.Config{},
instanceFactory: func(_ context.Context, svc *v1.Service, _ *sync.WaitGroup) (*instance.Instance, error) {
called = true
return &instance.Instance{ServiceUID: svc.UID, ServiceSnapshot: svc.DeepCopy()}, nil
},
}
created, err := processor.prepareServiceInstance(context.Background(), service, &sync.WaitGroup{})
if err != nil {
t.Fatalf("prepareServiceInstance() error = %v", err)
}
if !called {
t.Fatal("prepareServiceInstance() bypassed the shared instance factory")
}
if created == nil || !created.AddCalled {
t.Fatal("prepareServiceInstance() did not return an added instance")
}
}
func TestStopMarksServiceInstanceForReconfiguration(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
processor := &Processor{ServiceInstances: []*instance.Instance{{
ServiceUID: uid,
ServiceSnapshot: service,
AddCalled: true,
}}}
svcCtx := servicecontext.New(context.Background())
processor.svcMap.Store(uid, svcCtx)
processor.Stop()
if svcCtx.Ctx.Err() == nil {
t.Fatal("Stop did not cancel the Service context")
}
if svcCtx.StartWatching() {
t.Fatal("stopped Service context reacquired watcher ownership")
}
action := processor.getServiceInstanceAction(service)
if action != ActionAdd {
t.Fatalf("action after Stop() = %q, want %q", action, ActionAdd)
}
}
func TestAddServiceAfterDeleteTracksOneFreshInstance(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
processor := &Processor{
config: &kubevip.Config{DisableServiceUpdates: true, EnableServicesElection: true},
ServiceInstances: []*instance.Instance{{ServiceUID: uid, ServiceSnapshot: service}},
nodeLabelManager: &testLabeler{},
}
action := processor.getServiceInstanceAction(service)
if action != ActionAdd {
t.Fatalf("getServiceInstanceAction() = %q, want ActionAdd", action)
}
if err := processor.deleteService(context.Background(), uid); err != nil {
t.Fatalf("deleteService() error = %v", err)
}
if err := processor.addService(context.Background(), service, &sync.WaitGroup{}); err != nil {
t.Fatalf("addService() error = %v", err)
}
current := processor.findServiceInstance(service)
if current == nil {
t.Fatal("addService() did not track a replacement instance")
}
if got := len(processor.ServiceInstances); got != 1 {
t.Fatalf("tracked instance count = %d, want 1", got)
}
if !current.AddCalled {
t.Fatal("replacement instance was not marked added")
}
}
func TestAddServiceCleansUpAfterConfigurationFailure(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
serviceInstance := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service}
labeler := &testLabeler{addErr: errors.New("add label")}
processor := &Processor{
config: &kubevip.Config{DisableServiceUpdates: true, EnableServicesElection: true},
ServiceInstances: []*instance.Instance{serviceInstance},
nodeLabelManager: labeler,
}
if err := processor.addService(context.Background(), service, &sync.WaitGroup{}); err == nil {
t.Fatal("addService() error = nil, want configuration failure")
}
if labeler.addCalls != 1 {
t.Fatalf("AddLabel calls = %d, want 1", labeler.addCalls)
}
if got := processor.findServiceInstance(service); got != nil {
t.Fatal("configuration failure left a partial instance tracked")
}
}
func TestDeleteServiceKeepsInstanceWhenLabelRemovalFails(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
serviceInstance := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service, LabelAdded: true}
processor := &Processor{
config: &kubevip.Config{},
ServiceInstances: []*instance.Instance{serviceInstance},
nodeLabelManager: &testLabeler{removeErr: errors.New("remove label")},
}
if err := processor.deleteService(context.Background(), uid); err == nil {
t.Fatal("deleteService() error = nil, want label removal error")
}
if got := processor.findServiceInstance(service); got != serviceInstance {
t.Fatal("failed deletion removed the Service instance, preventing cleanup retry")
}
}
func TestDeleteServiceInstanceDoesNotDeleteReplacement(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
failedInstance := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service}
replacement := &instance.Instance{ServiceUID: uid, ServiceSnapshot: service.DeepCopy()}
processor := &Processor{
config: &kubevip.Config{},
ServiceInstances: []*instance.Instance{replacement},
nodeLabelManager: &testLabeler{},
}
if err := processor.deleteServiceInstance(context.Background(), failedInstance); err != nil {
t.Fatalf("deleteServiceInstance() error = %v", err)
}
if got := processor.findServiceInstance(service); got != replacement {
t.Fatal("failed-add cleanup removed a replacement instance")
}
}
func TestDeleteTrackedServiceCleansUpElectedServiceImmediately(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
processor := &Processor{
config: &kubevip.Config{EnableServicesElection: true},
ServiceInstances: []*instance.Instance{{ServiceUID: uid, ServiceSnapshot: service}},
leaseMgr: lease.NewManager(),
}
svcCtx := servicecontext.New(context.Background())
processor.svcMap.Store(uid, svcCtx)
leaseNamespace, serviceLease := lease.ServiceName(service)
leaseID := lease.NewID(processor.config.LeaderElectionType, leaseNamespace, serviceLease)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
if _, joined := processor.joinServiceElection(svcCtx, service, generation); !joined {
t.Fatal("service did not join its election")
}
if err := processor.deleteTrackedService(service); err != nil {
t.Fatalf("deleteTrackedService() error = %v", err)
}
if got := processor.findServiceInstance(service); got != nil {
t.Fatal("deleted elected Service instance remained tracked")
}
if _, ok := processor.svcMap.Load(uid); ok {
t.Fatal("deleted Service context remained tracked after cleanup")
}
if processor.leaseMgr.Get(leaseID) != nil {
t.Fatal("deleted Service lease remained available for a replacement")
}
}
func TestDeleteTrackedServiceReturnsPersistentCleanupFailure(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
labeler := &testLabeler{removeErr: errors.New("permanent remove label")}
processor := &Processor{
config: &kubevip.Config{},
ServiceInstances: []*instance.Instance{{ServiceUID: uid, ServiceSnapshot: service, LabelAdded: true}},
nodeLabelManager: labeler,
leaseMgr: lease.NewManager(),
}
svcCtx := servicecontext.New(context.Background())
processor.svcMap.Store(uid, svcCtx)
leaseNamespace, serviceLease := lease.ServiceName(service)
leaseID := lease.NewID(processor.config.LeaderElectionType, leaseNamespace, serviceLease)
svcCtx.SignalReadiness()
generation, _, _, _ := svcCtx.ReadinessState()
if _, joined := processor.joinServiceElection(svcCtx, service, generation); !joined {
t.Fatal("service did not join its election")
}
if err := processor.deleteTrackedService(service); err == nil {
t.Fatal("deleteTrackedService() error = nil, want cleanup failure")
}
if got := processor.findServiceInstance(service); got == nil {
t.Fatal("persistent cleanup failure removed the Service instance")
}
if got, err := processor.getServiceContext(uid); err != nil || got != svcCtx {
t.Fatalf("failed cleanup did not retain its context for retry: got %v, err %v", got, err)
}
if processor.leaseMgr.Get(leaseID) != nil {
t.Fatal("failed cleanup left the retired lease available to a replacement")
}
labeler.removeErr = nil
if err := processor.deleteTrackedService(service); err != nil {
t.Fatalf("deleteTrackedService() retry error = %v", err)
}
if got := processor.findServiceInstance(service); got != nil {
t.Fatal("retry did not remove the Service instance")
}
}
func TestServiceSnapshotsCopiesMutableServiceState(t *testing.T) {
uid := types.UID("service-a")
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
processor := &Processor{ServiceInstances: []*instance.Instance{{
ServiceUID: uid,
ServiceSnapshot: service,
}}}
snapshots := processor.ServiceSnapshots()
if len(snapshots) != 1 {
t.Fatalf("ServiceSnapshots() count = %d, want 1", len(snapshots))
}
service.Namespace = "changed"
if snapshots[0].Namespace != "default" {
t.Fatal("ServiceSnapshots() returned mutable Service state")
}
}
func TestServiceSnapshotsSerializesSnapshotReplacement(t *testing.T) {
uid := types.UID("service-a")
serviceInstance := &instance.Instance{
ServiceUID: uid,
ServiceSnapshot: &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}},
}
processor := &Processor{ServiceInstances: []*instance.Instance{serviceInstance}}
var wg sync.WaitGroup
wg.Go(func() {
for range 100 {
unlockService := processor.lockService(uid)
serviceInstance.ServiceSnapshot = &v1.Service{ObjectMeta: metav1.ObjectMeta{
UID: uid, Name: "service-a", Namespace: "default",
}}
unlockService()
}
})
wg.Go(func() {
for range 100 {
snapshots := processor.ServiceSnapshots()
if len(snapshots) != 1 || snapshots[0].UID != uid {
t.Errorf("ServiceSnapshots() = %+v, want one snapshot for %q", snapshots, uid)
return
}
}
})
wg.Wait()
}
// TestEndpointReconcileWaitsForServiceLock pins the wiring that stops a late
// endpoint event from reprogramming the datapath of a Service that is being torn
// down. The endpoint processor must take the same per-UID lock as deletion, so a
// reconcile cannot interleave with cleanup and re-add a route nobody owns.
func TestEndpointReconcileWaitsForServiceLock(t *testing.T) {
uid := types.UID("service-a")
config := &kubevip.Config{}
processor := &Processor{
config: config,
serviceLocks: keymutex.NewHashed(concurrentServiceLocks),
}
epProcessor := endpoints.NewEndpointProcessor(config, providers.NewEndpointslices(), nil,
&processor.ServiceInstances, &processor.instancesMutex, nil, nil, nil, processor.lockService)
unlockService := processor.lockService(uid)
service := &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default", UID: uid},
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster},
}
lastKnown := ""
reconciled := make(chan error, 1)
go func() {
_, err := epProcessor.Reconcile(
servicecontext.New(context.Background()),
watch.Event{
Type: watch.Modified,
Object: &discoveryv1.EndpointSlice{ObjectMeta: metav1.ObjectMeta{Name: "slice-1"}},
},
&lastKnown,
service,
"node-1",
&sync.WaitGroup{},
nil,
nil,
)
reconciled <- err
}()
select {
case <-reconciled:
unlockService()
t.Fatal("endpoint reconcile ignored the Service lock held by deletion")
case <-time.After(50 * time.Millisecond):
}
unlockService()
select {
case err := <-reconciled:
if err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("endpoint reconcile did not resume after the Service lock was released")
}
}

View File

@@ -1,33 +0,0 @@
package services
import (
"context"
"sync"
"testing"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
)
func TestWatchedFlagConcurrentWithWatcherTeardown(t *testing.T) {
svcCtx := servicecontext.New(context.Background())
start := make(chan struct{})
var wg sync.WaitGroup
wg.Go(func() {
<-start
for range 1000 {
svcCtx.SetWatched(false)
}
})
wg.Go(func() {
<-start
for range 1000 {
if !svcCtx.IsWatchedLocked() {
svcCtx.SetWatched(true)
}
}
})
close(start)
wg.Wait()
}

View File

@@ -0,0 +1,333 @@
//go:build linux
package services
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"runtime"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netns"
coordinationv1 "k8s.io/api/coordination/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
const recoveryProtocol = 248
// requireNetworkNamespaces makes the privileged CI job fail instead of silently
// skipping when it cannot enter a network namespace.
var requireNetworkNamespaces = os.Getenv("KUBE_VIP_REQUIRE_NETNS") != ""
func TestRecoverServiceAddressesUsesLeaseHolderIdentity(t *testing.T) {
for _, test := range []struct {
name string
holder string
remain bool
}{
{name: "current node retains address", holder: "node-a", remain: true},
{name: "remote node removes address", holder: "node-b", remain: false},
} {
t.Run(test.name, func(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
originalNamespace, err := netns.Get()
if err != nil {
t.Fatalf("getting current network namespace: %v", err)
}
defer originalNamespace.Close()
testNamespace, err := netns.New()
if err != nil {
if requireNetworkNamespaces {
t.Fatalf("creating isolated network namespace: %v", err)
}
t.Skipf("creating isolated network namespace: %v", err)
}
defer testNamespace.Close()
defer func() {
if err := netns.Set(originalNamespace); err != nil {
t.Errorf("restoring network namespace: %v", err)
}
}()
loopback, err := netlink.LinkByName("lo")
if err != nil {
t.Fatalf("getting loopback interface: %v", err)
}
if err := netlink.LinkSetUp(loopback); err != nil {
t.Fatalf("bringing up loopback interface: %v", err)
}
link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvrecover0"}}
if err := netlink.LinkAdd(link); err != nil {
t.Fatalf("creating test interface: %v", err)
}
if err := netlink.LinkSetUp(link); err != nil {
t.Fatalf("bringing test interface up: %v", err)
}
address, err := netlink.ParseAddr("192.0.2.10/32")
if err != nil {
t.Fatalf("parsing test address: %v", err)
}
address.Protocol = recoveryProtocol
if err := netlink.AddrReplace(link, address); err != nil {
t.Fatalf("adding tagged test address: %v", err)
}
clientSet := recoveryTestClient(t, test.holder)
processor := &Processor{
config: &kubevip.Config{
EnableServicesElection: true,
LeaderElectionType: "kubernetes",
NodeName: "node-a",
RoutingProtocol: recoveryProtocol,
ServiceNamespace: "default",
},
clientSet: clientSet,
lbClassFilter: lbClassFilter,
}
if err := processor.RecoverAddresses(context.Background()); err != nil {
t.Fatalf("recovering addresses: %v", err)
}
addresses, err := netlink.AddrList(link, netlink.FAMILY_V4)
if err != nil {
t.Fatalf("listing test addresses: %v", err)
}
found := false
for _, configured := range addresses {
if configured.IP.String() == "192.0.2.10" {
found = true
}
}
if found != test.remain {
t.Fatalf("tagged address present = %t, want %t", found, test.remain)
}
})
}
}
func TestServiceAddressRetainedUsesGlobalLeaseHolder(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default"}}
for _, test := range []struct {
name string
holder string
retain bool
}{
{name: "current node", holder: "node-a", retain: true},
{name: "remote node", holder: "node-b", retain: false},
} {
t.Run(test.name, func(t *testing.T) {
processor := &Processor{
config: &kubevip.Config{
EnableARP: true,
NodeName: "node-a",
LeaderElectionType: "kubernetes",
ServicesLeaseName: "default/kubevip-service",
},
clientSet: recoveryTestClient(t, test.holder),
}
retained, err := processor.serviceAddressRetained(context.Background(), service, make(map[string]string))
if err != nil {
t.Fatalf("checking global lease ownership: %v", err)
}
if retained != test.retain {
t.Fatalf("retained = %t, want %t", retained, test.retain)
}
})
}
}
func TestRetainControlPlaneVIPsUsesLeaseHolder(t *testing.T) {
for _, test := range []struct {
name string
holder string
retain bool
}{
{name: "current node", holder: "node-a", retain: true},
{name: "remote node", holder: "node-b", retain: false},
} {
t.Run(test.name, func(t *testing.T) {
processor := &Processor{
config: &kubevip.Config{
EnableControlPlane: true,
NodeName: "node-a",
LeaderElectionType: "kubernetes",
VIP: "2001:db8::10",
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
EnableLeaderElection: true,
LeaseName: "default/kubevip-service",
},
},
clientSet: recoveryTestClient(t, test.holder),
}
retained := make(map[string]struct{})
canClean, err := processor.retainControlPlaneVIPs(context.Background(), make(map[string]string), retained)
if err != nil {
t.Fatalf("checking control-plane lease ownership: %v", err)
}
if !canClean {
t.Fatal("known control-plane IP unexpectedly disabled recovery")
}
_, found := retained["2001:db8::10"]
if found != test.retain {
t.Fatalf("control-plane VIP retained = %t, want %t", found, test.retain)
}
})
}
}
// Without leader election every control-plane node advertises the VIP, so a
// lease held elsewhere must not cause recovery to sweep it.
func TestRetainControlPlaneVIPsWithoutLeaderElection(t *testing.T) {
processor := &Processor{
config: &kubevip.Config{
EnableControlPlane: true,
NodeName: "node-a",
LeaderElectionType: "kubernetes",
VIP: "2001:db8::10",
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
LeaseName: "default/kubevip-service",
},
},
clientSet: recoveryTestClient(t, "node-b"),
}
retained := make(map[string]struct{})
canClean, err := processor.retainControlPlaneVIPs(context.Background(), make(map[string]string), retained)
if err != nil {
t.Fatalf("checking control-plane ownership: %v", err)
}
if !canClean {
t.Fatal("known control-plane IP unexpectedly disabled recovery")
}
if _, found := retained["2001:db8::10"]; !found {
t.Fatal("control-plane VIP swept while running without leader election")
}
}
func TestRetainAnnotatedLeaseVIPsAcrossInstances(t *testing.T) {
annotations, err := kubevip.WithLeaseVIPs(nil, "release_b", recoveryProtocol, []string{"192.0.2.20"})
if err != nil {
t.Fatalf("WithLeaseVIPs() error = %v", err)
}
holder := "node-a"
clientSet := recoveryTestClientWithLeases(t, holder, []coordinationv1.Lease{{
ObjectMeta: metav1.ObjectMeta{Name: "release-b", Namespace: "other", Annotations: annotations},
Spec: coordinationv1.LeaseSpec{HolderIdentity: &holder},
}})
processor := &Processor{
config: &kubevip.Config{NodeName: holder, RoutingProtocol: recoveryProtocol},
clientSet: clientSet,
}
holders := make(map[string]string)
retained := make(map[string]struct{})
if err := processor.retainAnnotatedLeaseVIPs(context.Background(), holders, retained); err != nil {
t.Fatalf("retainAnnotatedLeaseVIPs() error = %v", err)
}
if _, found := retained["192.0.2.20"]; !found {
t.Fatal("locally held VIP from another kube-vip instance was not retained")
}
if holders["other/release-b"] != holder {
t.Fatal("Lease holder cache was not populated from the ownership scan")
}
}
func TestLeaseOwnershipCurrentRejectsExpiredLease(t *testing.T) {
now := time.Unix(1_000, 0)
duration := int32(15)
renewed := metav1.NewMicroTime(now.Add(-time.Minute))
resource := &coordinationv1.Lease{Spec: coordinationv1.LeaseSpec{
RenewTime: &renewed,
LeaseDurationSeconds: &duration,
}}
if leaseOwnershipCurrent(resource, now) {
t.Fatal("expired Lease ownership was treated as current")
}
renewed = metav1.NewMicroTime(now.Add(-time.Second))
resource.Spec.RenewTime = &renewed
if !leaseOwnershipCurrent(resource, now) {
t.Fatal("unexpired Lease ownership was rejected")
}
}
func TestRecoverAddressesRemainsRetryableForHostnameControlPlaneVIP(t *testing.T) {
processor := &Processor{
config: &kubevip.Config{
EnableControlPlane: true,
LeaderElectionType: "kubernetes",
RoutingProtocol: recoveryProtocol,
ServiceNamespace: "default",
Address: "api.example.test",
},
clientSet: recoveryTestClient(t, "node-a"),
lbClassFilter: lbClassFilter,
}
if err := processor.RecoverAddresses(context.Background()); err != nil {
t.Fatalf("RecoverAddresses() error = %v", err)
}
if processor.recovered {
t.Fatal("hostname-backed control-plane VIP disabled future recovery")
}
}
func recoveryTestClient(t *testing.T, holder string) *kubernetes.Clientset {
return recoveryTestClientWithLeases(t, holder, nil)
}
func recoveryTestClientWithLeases(t *testing.T, holder string, leases []coordinationv1.Lease) *kubernetes.Clientset {
t.Helper()
clientSet, err := kubernetes.NewForConfig(&rest.Config{
Host: "https://recovery.test",
Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
var object any
switch request.URL.Path {
case "/apis/coordination.k8s.io/v1/leases":
object = &coordinationv1.LeaseList{Items: leases}
case "/apis/coordination.k8s.io/v1/namespaces/default/leases":
object = &coordinationv1.LeaseList{Items: leases}
case "/api/v1/namespaces/default/services":
object = &v1.ServiceList{Items: []v1.Service{{
ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default"},
Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer, LoadBalancerIP: "192.0.2.10"},
}}}
case "/apis/coordination.k8s.io/v1/namespaces/default/leases/kubevip-service":
object = &coordinationv1.Lease{
ObjectMeta: metav1.ObjectMeta{Name: "kubevip-service", Namespace: "default"},
Spec: coordinationv1.LeaseSpec{HolderIdentity: &holder},
}
default:
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewReader(nil)), Request: request}, nil
}
body, err := json.Marshal(object)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(body)),
Request: request,
}, nil
}),
})
if err != nil {
t.Fatalf("creating Kubernetes client: %v", err)
}
return clientSet
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}

View File

@@ -13,7 +13,6 @@ import (
log "log/slog"
"github.com/google/go-cmp/cmp"
"github.com/vishvananda/netlink"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -25,8 +24,6 @@ import (
"github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/nftables"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/upnp"
"github.com/kube-vip/kube-vip/pkg/utils"
@@ -45,31 +42,32 @@ const (
)
func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, wg *sync.WaitGroup, usesLeaderElection bool) error {
return p.syncServicesWithContext(ctx.Ctx, ctx, svc, wg, usesLeaderElection)
}
func (p *Processor) syncServicesWithContext(operationCtx context.Context, svcCtx *servicecontext.Context,
svc *v1.Service, wg *sync.WaitGroup, usesLeaderElection bool) error {
log.Debug("[STARTING] Service Sync", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
// Iterate through the synchronising services
action, instance := p.getServiceInstanceAction(svc)
action := p.getServiceInstanceAction(svc)
switch action {
case ActionDelete:
log.Debug("[service] delete", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
if err := p.deleteService(ctx.Ctx, svc.UID); err != nil {
if err := p.deleteService(operationCtx, svc.UID); err != nil {
return fmt.Errorf("error deleting service %s/%s: %w", svc.Namespace, svc.Name, err)
}
case ActionAdd:
log.Debug("[service] add", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
if instance != nil {
instance.AddCalled = true
}
if !usesLeaderElection {
select {
case <-ctx.Ctx.Done():
releaseReadiness, ready := svcCtx.WaitForReadiness()
if !ready {
return nil
case <-ctx.GetEndpointsReady():
}
defer releaseReadiness()
}
if err := p.addService(ctx.Ctx, instance, svc, wg); err != nil {
if err := p.addService(operationCtx, svc, wg); err != nil {
return fmt.Errorf("error adding service %s/%s: %w", svc.Namespace, svc.Name, err)
}
@@ -80,7 +78,7 @@ func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, w
// LB IP, the initial addService call may have missed the SNAT configuration because
// ActiveEndpoint was not yet present. Re-run it here.
if svc.Annotations[kubevip.Egress] == "true" && svc.Annotations[kubevip.ActiveEndpoint] != "" {
if err := p.updateEgressConfiguration(ctx.Ctx, svc); err != nil {
if err := p.updateEgressConfiguration(operationCtx, svc); err != nil {
log.Warn("[service] egress reconfigure on ActionNone", "service", svc.Name, "namespace", svc.Namespace, "err", err)
}
}
@@ -89,65 +87,64 @@ func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, w
return nil
}
func (p *Processor) getServiceInstanceAction(svc *v1.Service) (ServiceInstanceAction, *instance.Instance) {
func (p *Processor) getServiceInstanceAction(svc *v1.Service) ServiceInstanceAction {
unlockService := p.lockService(svc.UID)
defer unlockService()
// protect against multiple calls
// get the annotations or legacy values from manual configuration
addresses, hostnames := instance.FetchServiceAddresses(svc)
// get the status information of the LB Service
statusAddresses, _ := instance.FetchLoadBalancerIngress(svc)
p.mutex.Lock()
defer p.mutex.Unlock()
for _, instance := range p.ServiceInstances {
if instance != nil && instance.ServiceSnapshot.UID == svc.UID {
if !instance.AddCalled {
return ActionAdd, instance
}
for _, address := range addresses {
// handle the case where the service instance needs to be deleted
if instance.IsDHCPv4 {
if address != "0.0.0.0" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, instance.DHCPInterfaceIPv4) {
return ActionDelete, instance
}
} else {
if address == "0.0.0.0" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete, instance
}
}
if instance.IsDHCPv6 {
if address != "::" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, instance.DHCPInterfaceIPv6) {
return ActionDelete, instance
}
} else {
if address == "::" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete, instance
}
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !comparePortsAndPortStatuses(svc) {
return ActionDelete, instance
}
}
// If we reach here, it means the service instance matches the service UID and is not a DHCP service, so we can return "no action"
return ActionNone, instance
inst := p.findServiceInstance(svc)
if inst != nil {
if !inst.AddCalled {
return ActionAdd
}
for _, address := range addresses {
// handle the case where the service instance needs to be deleted
if inst.IsDHCPv4 {
if address != "0.0.0.0" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, inst.DHCPInterfaceIPv4) {
return ActionDelete
}
} else {
if address == "0.0.0.0" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete
}
}
if inst.IsDHCPv6 {
if address != "::" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, inst.DHCPInterfaceIPv6) {
return ActionDelete
}
} else {
if address == "::" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete
}
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !comparePortsAndPortStatuses(svc) {
return ActionDelete
}
}
// If we reach here, it means the service instance matches the service UID and is not a DHCP service, so we can return "no action"
return ActionNone
}
if len(addresses) > 0 || len(hostnames) > 0 {
log.Debug("no matching service instance found", "service", svc.Name, "namespace", svc.Namespace, "uid", svc.UID, "addresses", addresses, "hostnames", hostnames)
return ActionAdd, nil // If no matching instance is found, we need to add a new service instance
return ActionAdd // If no matching instance is found, we need to add a new service instance
}
return ActionNone, nil
return ActionNone
}
func comparePortsAndPortStatuses(svc *v1.Service) bool {
@@ -166,118 +163,121 @@ func comparePortsAndPortStatuses(svc *v1.Service) bool {
return true
}
func (p *Processor) addService(ctx context.Context, inst *instance.Instance, svc *v1.Service, wg *sync.WaitGroup) error {
// protect against addService while reading
p.mutex.Lock()
defer p.mutex.Unlock()
func (p *Processor) addService(ctx context.Context, svc *v1.Service, wg *sync.WaitGroup) error {
startTime := time.Now()
var err error
inst, err := p.prepareServiceInstance(ctx, svc, wg)
if err != nil {
return err
}
if inst == nil {
inst, err = instance.NewInstance(ctx, svc, p.config, p.intfMgr, p.arpMgr, p.routeMgr, p.nodeLabelManager, wg)
if err != nil {
return err
}
inst.AddCalled = true
p.ServiceInstances = append(p.ServiceInstances, inst)
return nil
}
if err := p.configureService(ctx, inst, svc, wg); err != nil {
return fmt.Errorf("failed to configure service: %w", err)
cleanupErr := p.deleteServiceInstance(context.WithoutCancel(ctx), inst)
if cleanupErr != nil {
return fmt.Errorf("configure service %s/%s: %w; cleanup: %w", svc.Namespace, svc.Name, err, cleanupErr)
}
return err
}
// add the label to the node after adding the service
labels := generateLabelsFromService(svc, kubevip.ServiceProvided)
if err := p.nodeLabelManager.AddLabel(labels); err != nil {
return fmt.Errorf("error adding label to node: %w", err)
}
inst.LabelAdded = true
finishTime := time.Since(startTime)
log.Info("[service]", "service", svc.Name, "namespace", svc.Namespace, "synchronised in", fmt.Sprintf("%dms", finishTime.Milliseconds()))
return nil
}
// prepareServiceInstance finds or constructs the instance and marks it added. It
// acquires the Service lock for svc.UID; callers must not already hold it.
func (p *Processor) prepareServiceInstance(ctx context.Context, svc *v1.Service, wg *sync.WaitGroup) (*instance.Instance, error) {
unlockService := p.lockService(svc.UID)
defer unlockService()
if err := ctx.Err(); err != nil {
return nil, err
}
current := p.findServiceInstance(svc)
if current != nil {
if current.AddCalled {
return nil, nil
}
current.AddCalled = true
return current, nil
}
inst, err := p.createServiceInstance(ctx, svc, wg)
if err != nil {
return nil, err
}
inst.AddCalled = true
p.appendServiceInstance(inst)
return inst, nil
}
// configureService configures a tracked instance. It acquires the Service lock
// for svc.UID and verifies inst is still current; callers must not hold the lock.
func (p *Processor) configureService(ctx context.Context, inst *instance.Instance, svc *v1.Service, wg *sync.WaitGroup) error {
unlockService := p.lockService(svc.UID)
defer unlockService()
if err := ctx.Err(); err != nil {
return err
}
current := p.findServiceInstance(svc)
if current != inst {
return fmt.Errorf("service instance no longer active for %s/%s", svc.Namespace, svc.Name)
}
// is not a global leader election mode
if p.config.EnableServicesElection || (!p.config.EnableARP && !p.config.EnableLeaderElection) || (!p.config.EnableARP && !p.config.EnableRoutingTable) {
for x := range inst.VIPConfigs {
log.Debug("[service] starting loadbalancer for service", "name", svc.Name, "namespace", svc.Namespace, "uid", svc.UID)
if err := inst.Clusters[x].StartLoadBalancerService(ctx, inst.VIPConfigs[x], p.bgpServer, lease.ServiceNamespacedName(svc), wg); err != nil {
return fmt.Errorf("failed to start lb: %w", err)
}
if err := endpoints.StartService(ctx, svc, inst, p.bgpServer, wg); err != nil {
return fmt.Errorf("start service datapath: %w", err)
}
}
p.upnpMap(ctx, inst)
if inst.IsDHCPv4 {
wg.Go(func() {
index := -1
for i := range inst.VIPConfigs {
ip := net.ParseIP(inst.VIPConfigs[i].VIP)
if ip.To4() != nil {
index = i
break
}
}
if index == -1 {
log.Error("unable to find proper VIPConfig for the DHCPv4")
} else {
index := dhcpConfigIndex(inst.VIPConfigs, false)
if index == -1 {
log.Error("unable to find proper VIPConfig for the DHCPv4")
} else {
wg.Go(func() {
for {
select {
case <-ctx.Done():
log.Debug("IPv4 update watcher stopping")
return
case ip := <-inst.DHCPv4Client.IPChannel():
log.Debug("IP changed", "ip", ip)
inst.VIPConfigs[index].VIP = ip
inst.DHCPInterfaceIPv4 = ip
if !p.config.DisableServiceUpdates {
if err := p.updateStatus(ctx, inst); err != nil {
log.Warn("updating svc", "err", err)
}
if !p.updateDHCPAddress(ctx, svc, inst, index, ip, false) {
return
}
}
}
}
})
})
}
}
if inst.IsDHCPv6 {
wg.Go(func() {
index := -1
for i := range inst.VIPConfigs {
ip := net.ParseIP(inst.VIPConfigs[i].VIP)
if ip.To4() == nil {
index = i
break
}
}
if index == -1 {
log.Error("unable to find proper VIPConfig for the DHCPv6")
} else {
index := dhcpConfigIndex(inst.VIPConfigs, true)
if index == -1 {
log.Error("unable to find proper VIPConfig for the DHCPv6")
} else {
wg.Go(func() {
for {
select {
case <-ctx.Done():
log.Debug("IPv6 update watcher stopping")
return
case ip := <-inst.DHCPv6Client.IPChannel():
log.Debug("IP changed", "ip", ip)
inst.VIPConfigs[index].VIP = ip
inst.DHCPInterfaceIPv6 = ip
if !p.config.DisableServiceUpdates {
if err := p.updateStatus(ctx, inst); err != nil {
log.Warn("updating svc", "err", err)
}
if !p.updateDHCPAddress(ctx, svc, inst, index, ip, true) {
return
}
}
}
}
})
})
}
}
if !p.config.DisableServiceUpdates {
@@ -287,26 +287,27 @@ func (p *Processor) configureService(ctx context.Context, inst *instance.Instanc
}
}
serviceIPs, _ := instance.FetchServiceAddresses(svc)
egressService := serviceSnapshotForEgress(inst, svc)
serviceIPs, _ := instance.FetchServiceAddresses(egressService)
// Check if we need to flush any conntrack connections (due to some dangling conntrack connections)
if svc.Annotations[kubevip.FlushContrack] == "true" {
if egressService.Annotations[kubevip.FlushContrack] == "true" {
log.Debug("[service] Flushing conntrack rules", "service", svc.Name, "namespace", svc.Namespace)
log.Debug("[service] Flushing conntrack rules", "service", egressService.Name, "namespace", egressService.Namespace)
for _, serviceIP := range serviceIPs {
err := vip.DeleteExistingSessions(serviceIP, false, svc.Annotations[kubevip.EgressDestinationPorts], svc.Annotations[kubevip.EgressSourcePorts])
err := vip.DeleteExistingSessions(serviceIP, false, egressService.Annotations[kubevip.EgressDestinationPorts], egressService.Annotations[kubevip.EgressSourcePorts])
if err != nil {
log.Error("[service] flushing any remaining egress connections", "service", svc.Name, "namespace", svc.Namespace, "err", err)
log.Error("[service] flushing any remaining egress connections", "service", egressService.Name, "namespace", egressService.Namespace, "err", err)
}
err = vip.DeleteExistingSessions(serviceIP, true, svc.Annotations[kubevip.EgressDestinationPorts], svc.Annotations[kubevip.EgressSourcePorts])
err = vip.DeleteExistingSessions(serviceIP, true, egressService.Annotations[kubevip.EgressDestinationPorts], egressService.Annotations[kubevip.EgressSourcePorts])
if err != nil {
log.Error("[service] flushing any remaining ingress connections", "service", svc.Name, "namespace", svc.Namespace, "err", err)
log.Error("[service] flushing any remaining ingress connections", "service", egressService.Name, "namespace", egressService.Namespace, "err", err)
}
}
}
// Check if egress is enabled on the service, if so we'll need to configure some rules
if svc.Annotations[kubevip.Egress] == "true" && len(serviceIPs) > 0 {
log.Debug("[service] enabling egress", "service", svc.Name, "namespace", svc.Namespace)
if egressService.Annotations[kubevip.Egress] == "true" && len(serviceIPs) > 0 {
log.Debug("[service] enabling egress", "service", egressService.Name, "namespace", egressService.Namespace)
// If we'er not using NFtables, then ensure that the correct iptables modules are loaded
if p.config.EgressWithNftables {
// Ensure that kernel modules are loaded and report back missing modules.
@@ -324,19 +325,19 @@ func (p *Processor) configureService(ctx context.Context, inst *instance.Instanc
var podIP string
errList := []error{}
configuredRules := 0
useInternalNftables := svc.Annotations[kubevip.EgressInternal] != "" || p.config.EgressWithNftables
useInternalNftables := egressService.Annotations[kubevip.EgressInternal] != "" || p.config.EgressWithNftables
preparedFamilies := map[bool]bool{}
// Should egress be IPv6
if svc.Annotations[kubevip.EgressIPv6] == "true" {
if egressService.Annotations[kubevip.EgressIPv6] == "true" {
// Does the service have an active IPv6 endpoint
if svc.Annotations[kubevip.ActiveEndpointIPv6] != "" {
if egressService.Annotations[kubevip.ActiveEndpointIPv6] != "" {
for _, serviceIP := range serviceIPs {
if !p.config.EnableEndpoints && utils.IsIPv6(serviceIP) {
podIP = svc.Annotations[kubevip.ActiveEndpointIPv6]
podIP = egressService.Annotations[kubevip.ActiveEndpointIPv6]
if useInternalNftables && !preparedFamilies[true] {
if err := p.prepareEgressNftablesTable(string(svc.UID), true); err != nil {
if err := p.prepareEgressNftablesTable(string(egressService.UID), true); err != nil {
errList = append(errList, err)
continue
}
@@ -344,35 +345,35 @@ func (p *Processor) configureService(ctx context.Context, inst *instance.Instanc
}
applied := false
err := p.configureEgress(ctx, serviceIP, podIP, svc.Namespace, string(svc.UID), svc.Annotations, &applied)
err := p.configureEgress(ctx, serviceIP, podIP, egressService.Namespace, string(egressService.UID), egressService.Annotations, &applied)
if err != nil {
errList = append(errList, err)
log.Warn("[service] configuring egress IPv6", "service", svc.Name, "namespace", svc.Namespace, "err", err)
log.Warn("[service] configuring egress IPv6", "service", egressService.Name, "namespace", egressService.Namespace, "err", err)
} else if applied {
configuredRules++
}
}
}
}
} else if svc.Annotations[kubevip.ActiveEndpoint] != "" { // Not expected to be IPv6, so should be an IPv4 address
} else if egressService.Annotations[kubevip.ActiveEndpoint] != "" { // Not expected to be IPv6, so should be an IPv4 address
for _, serviceIP := range serviceIPs {
podIPs := svc.Annotations[kubevip.ActiveEndpoint]
podIPs := egressService.Annotations[kubevip.ActiveEndpoint]
if !p.config.EnableEndpoints && utils.IsIPv6(serviceIP) {
podIPs = svc.Annotations[kubevip.ActiveEndpointIPv6]
podIPs = egressService.Annotations[kubevip.ActiveEndpointIPv6]
}
ipv6 := utils.IsIPv6(serviceIP)
if useInternalNftables && !preparedFamilies[ipv6] {
if err := p.prepareEgressNftablesTable(string(svc.UID), ipv6); err != nil {
if err := p.prepareEgressNftablesTable(string(egressService.UID), ipv6); err != nil {
errList = append(errList, err)
continue
}
preparedFamilies[ipv6] = true
}
applied := false
err := p.configureEgress(ctx, serviceIP, podIPs, svc.Namespace, string(svc.UID), svc.Annotations, &applied)
err := p.configureEgress(ctx, serviceIP, podIPs, egressService.Namespace, string(egressService.UID), egressService.Annotations, &applied)
if err != nil {
errList = append(errList, err)
log.Warn("[service] configuring egress IPv4", "service", svc.Name, "namespace", svc.Namespace, "err", err)
log.Warn("[service] configuring egress IPv4", "service", egressService.Name, "namespace", egressService.Namespace, "err", err)
} else if applied {
configuredRules++
}
@@ -380,53 +381,130 @@ func (p *Processor) configureService(ctx context.Context, inst *instance.Instanc
}
if len(errList) == 0 {
if configuredRules > 0 && useInternalNftables {
if err := p.updateEgressNftablesTableAnnotation(ctx, svc); err != nil {
if err := p.updateEgressNftablesTableAnnotation(ctx, egressService); err != nil {
return err
}
}
}
}
// Configure WireGuard DNAT rules if WireGuard is enabled
if p.config.EnableWireguard {
log.Debug("[service] configuring WireGuard DNAT rules", "service", svc.Name, "namespace", svc.Namespace)
if err := p.addServiceWireguard(ctx, svc); err != nil {
log.Warn("[service] failed to configure WireGuard DNAT", "service", svc.Name, "namespace", svc.Namespace, "err", err)
// Don't fail the entire service if WireGuard config fails
}
labels := generateLabelsFromService(svc, kubevip.ServiceProvided)
if err := p.nodeLabelManager.AddLabel(labels); err != nil {
return fmt.Errorf("error adding label to node: %w", err)
}
inst.LabelAdded = true
return nil
}
func (p *Processor) deleteService(ctx context.Context, uid types.UID) error {
// protect multiple calls
p.mutex.Lock()
defer p.mutex.Unlock()
// dhcpConfigIndex reads VIP configuration state. The caller must hold the
// Service lock when configs belong to a tracked instance.
func dhcpConfigIndex(configs []*kubevip.Config, ipv6 bool) int {
for index, config := range configs {
ip := net.ParseIP(config.VIP)
if ip != nil && (ip.To4() == nil) == ipv6 {
return index
}
}
return -1
}
var updatedInstances []*instance.Instance
var serviceInstance *instance.Instance
found := false
for x := range p.ServiceInstances {
log.Debug("[service] lookup", "target UID", uid, "found UID", p.ServiceInstances[x].ServiceSnapshot.UID, "name", p.ServiceInstances[x].ServiceSnapshot.Name, "namespace", p.ServiceInstances[x].ServiceSnapshot.Namespace)
// Add the running services to the new array
if p.ServiceInstances[x].ServiceSnapshot.UID != uid {
updatedInstances = append(updatedInstances, p.ServiceInstances[x])
} else {
// Flip the found when we match
found = true
serviceInstance = p.ServiceInstances[x]
// updateDHCPAddress applies one lease update. It acquires the Service lock for
// svc.UID and returns false if inst is no longer current; callers must not
// already hold the lock.
func (p *Processor) updateDHCPAddress(ctx context.Context, svc *v1.Service, inst *instance.Instance, index int, ip string, ipv6 bool) bool {
unlockService := p.lockService(svc.UID)
defer unlockService()
if p.findServiceInstance(svc) != inst {
return false
}
log.Debug("IP changed", "ip", ip)
inst.VIPConfigs[index].VIP = ip
if ipv6 {
inst.DHCPInterfaceIPv6 = ip
} else {
inst.DHCPInterfaceIPv4 = ip
}
if !p.config.DisableServiceUpdates {
if err := p.updateStatus(ctx, inst); err != nil {
log.Warn("updating svc", "err", err)
}
}
return true
}
// serviceSnapshotForEgress reads the tracked instance snapshot. The caller must
// hold the Service lock for inst.UID().
func serviceSnapshotForEgress(inst *instance.Instance, service *v1.Service) *v1.Service {
if inst == nil || inst.ServiceSnapshot == nil || service == nil {
return service
}
merged := service.DeepCopy()
if merged.Annotations == nil {
merged.Annotations = make(map[string]string)
}
merged.Annotations[kubevip.ActiveEndpoint] = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint]
merged.Annotations[kubevip.ActiveEndpointIPv6] = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpointIPv6]
return merged
}
// deleteService removes the tracked instance for uid. It acquires the Service
// lock; callers must not already hold it.
func (p *Processor) deleteService(ctx context.Context, uid types.UID, expectedCtx ...*servicecontext.Context) error {
unlockService := p.lockService(uid)
defer unlockService()
var expected *servicecontext.Context
if len(expectedCtx) > 0 {
expected = expectedCtx[0]
}
if expected != nil {
currentCtx, err := p.getServiceContext(uid)
if err != nil {
return err
}
if currentCtx != nil && currentCtx != expected {
return nil
}
}
// If we've been through all services and not found the correct one then error
if !found {
// TODO: - fix UX
// return fmt.Errorf("unable to find/stop service [%s]", uid)
log.Warn("unable to find/stop service", "uid", uid)
return p.deleteCurrentServiceByUID(ctx, uid)
}
// deleteCurrentServiceByUID removes a tracked instance. The caller must hold the
// Service lock for uid. A missing instance means cleanup already completed, so
// deletion is idempotent.
func (p *Processor) deleteCurrentServiceByUID(ctx context.Context, uid types.UID) error {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: uid}}
serviceInstance := p.findServiceInstance(service)
if serviceInstance == nil {
log.Debug("service instance already absent", "uid", uid)
return nil
}
return p.deleteCurrentService(ctx, serviceInstance)
}
// deleteServiceInstance removes expected only if it is still current. It
// acquires the Service lock for expected.UID; callers must not already hold it.
func (p *Processor) deleteServiceInstance(ctx context.Context, expected *instance.Instance) error {
if expected == nil {
return nil
}
unlockService := p.lockService(expected.UID())
defer unlockService()
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: expected.UID()}}
if p.findServiceInstance(service) != expected {
return nil
}
return p.deleteCurrentService(ctx, expected)
}
// deleteCurrentService removes the supplied tracked instance. The caller must
// hold the Service lock for serviceInstance.UID().
func (p *Processor) deleteCurrentService(ctx context.Context, serviceInstance *instance.Instance) error {
if serviceInstance.LabelAdded {
labels := generateLabelsFromService(serviceInstance.ServiceSnapshot, kubevip.ServiceProvided)
if err := p.nodeLabelManager.RemoveLabel(labels); err != nil {
@@ -434,134 +512,54 @@ func (p *Processor) deleteService(ctx context.Context, uid types.UID) error {
}
}
for _, c := range serviceInstance.Clusters {
for n := range c.Network {
c.Network[n].SetHasEndpoints(false)
}
p.serviceCleanupMu.Lock()
defer p.serviceCleanupMu.Unlock()
removed, updatedInstances := p.detachServiceInstance(serviceInstance.UID())
if removed != serviceInstance {
return nil
}
if err := endpoints.CleanupService(ctx, p.config, p.bgpServer, p.routeMgr, p.TunnelMgr, serviceInstance, updatedInstances); err != nil {
p.appendServiceInstance(serviceInstance)
return fmt.Errorf("cleanup service datapath: %w", err)
}
// Determine if this VIP is shared with other loadbalancers
shared := false
vipSet := make(map[string]interface{})
for x := range updatedInstances {
vips, _ := instance.FetchServiceAddresses(updatedInstances[x].ServiceSnapshot)
for _, vip := range vips { //updatedInstances[x].ServiceSnapshot.Spec.LoadBalancerIP {
vipSet[vip] = nil
}
}
vips, _ := instance.FetchServiceAddresses(serviceInstance.ServiceSnapshot)
for _, vip := range vips {
if _, found := vipSet[vip]; found {
shared = true
}
}
if p.config.EnableBGP {
endpoints.ClearBGPHostsByInstance(ctx, serviceInstance, p.bgpServer)
}
// ClearRoutesByInstance is reference-counted per route, so calling it here is safe
// even when the no-election path in Processor.Delete already cleared it.
if p.config.EnableRoutingTable {
if errs := endpoints.ClearRoutesByInstance(serviceInstance.ServiceSnapshot, serviceInstance, &p.ServiceInstances, p.routeMgr); len(errs) > 0 {
for _, err := range errs {
log.Error("unable to clear routes", "err", err)
}
}
}
internalNftablesEgress := serviceInstance.ServiceSnapshot.Annotations[kubevip.EgressInternal] != "" || p.config.EgressWithNftables
if serviceInstance.ServiceSnapshot.Annotations[kubevip.Egress] == "true" && internalNftablesEgress {
if err := nftables.DeleteSNATFromAllTables(string(serviceInstance.ServiceSnapshot.UID)); err != nil {
log.Error("[service] nftables egress teardown", "service", serviceInstance.ServiceSnapshot.Name, "err", err)
}
}
if !shared {
for x := range serviceInstance.Clusters {
serviceInstance.Clusters[x].Stop()
}
if serviceInstance.IsVLAN {
vlan, err := netlink.LinkByName(serviceInstance.VLANInterface)
if err != nil {
return fmt.Errorf("[service] error finding VLAN Interface: %v", err)
}
err = netlink.LinkDel(vlan)
if err != nil {
return fmt.Errorf("[service] error deleting VLAN interface : %v", err)
}
}
if serviceInstance.IsDHCPv4 || serviceInstance.IsDHCPv6 {
if serviceInstance.IsDHCPv4 {
serviceInstance.DHCPv4Client.Stop()
}
if serviceInstance.IsDHCPv6 {
serviceInstance.DHCPv6Client.Stop()
}
macvlan, err := netlink.LinkByName(serviceInstance.DHCPInterface)
if err != nil {
return fmt.Errorf("[service] error finding VIP Interface: %v", err)
}
err = netlink.LinkDel(macvlan)
if err != nil {
return fmt.Errorf("[service] error deleting DHCP Link : %v", err)
}
}
// We will need to tear down the egress
if serviceInstance.ServiceSnapshot.Annotations[kubevip.Egress] == "true" && !internalNftablesEgress {
if serviceInstance.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint] != "" {
log.Info("[service] egress re-write enabled", "service", serviceInstance.ServiceSnapshot.Name)
err := egress.Teardown(serviceInstance.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint], serviceInstance.ServiceSnapshot.Spec.LoadBalancerIP, serviceInstance.ServiceSnapshot.Namespace, string(serviceInstance.ServiceSnapshot.UID), serviceInstance.ServiceSnapshot.Annotations, p.config.EgressWithNftables)
if err != nil {
log.Error("[service] egress teardown", "err", err)
}
}
}
}
// Update the service array
p.ServiceInstances = updatedInstances
// Clean up WireGuard DNAT rules if WireGuard is enabled
if p.config.EnableWireguard {
log.Debug("[service] cleaning up WireGuard DNAT rules", "uid", uid, "name", serviceInstance.ServiceSnapshot.Name)
p.deleteServiceWireguard(ctx, serviceInstance.ServiceSnapshot)
}
log.Info("Removed instance from manager", "uid", uid, "name", serviceInstance.ServiceSnapshot.Name, "remaining advertised services", len(p.ServiceInstances))
log.Info("Removed instance from manager", "uid", serviceInstance.UID(), "name", serviceInstance.ServiceSnapshot.Name, "remaining advertised services", len(updatedInstances))
return nil
}
func (p *Processor) updateEgressConfiguration(ctx context.Context, svc *v1.Service) error {
p.mutex.Lock()
defer p.mutex.Unlock()
// updateEgressConfiguration updates egress state for the current instance. It
// acquires the Service lock for svc.UID; callers must not already hold it.
func (p *Processor) updateEgressConfiguration(ctx context.Context, svc *v1.Service, expected ...*instance.Instance) error {
unlockService := p.lockService(svc.UID)
defer unlockService()
i := instance.FindServiceInstance(svc, p.ServiceInstances)
i := p.findServiceInstance(svc)
if i == nil {
return fmt.Errorf("service instance not found for %s/%s", svc.Namespace, svc.Name)
}
if len(expected) > 0 && expected[0] != nil && i != expected[0] {
return nil
}
oldIPv4 := i.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint]
newIPv4 := svc.Annotations[kubevip.ActiveEndpoint]
oldIPv6 := i.ServiceSnapshot.Annotations[kubevip.ActiveEndpointIPv6]
newIPv6 := svc.Annotations[kubevip.ActiveEndpointIPv6]
oldEgressIPv6 := i.ServiceSnapshot.Annotations[kubevip.EgressIPv6] == "true"
newEgressIPv6 := svc.Annotations[kubevip.EgressIPv6] == "true"
// Skip update if endpoints haven't changed, without touching the API.
if oldIPv4 == newIPv4 && oldIPv6 == newIPv6 {
// Skip update if neither endpoints nor the selected egress family changed.
if oldIPv4 == newIPv4 && oldIPv6 == newIPv6 && oldEgressIPv6 == newEgressIPv6 {
return nil
}
// The svc snapshot may have been captured before the LB IP was assigned.
// Refresh from the API so FetchServiceAddresses sees the current ingress.
if current, err := p.clientSet.CoreV1().Services(svc.Namespace).Get(ctx, svc.Name, metav1.GetOptions{}); err == nil {
if current.UID != i.UID() {
return nil
}
// Preserve the caller-supplied annotations (ActiveEndpoint etc.) that triggered this call.
for k, v := range svc.Annotations {
if current.Annotations == nil {
@@ -582,17 +580,23 @@ func (p *Processor) updateEgressConfiguration(ctx context.Context, svc *v1.Servi
// Remove old egress rules if they exist
if oldIPv4 != "" || oldIPv6 != "" {
oldEndpoint := oldIPv4
if oldEndpoint == "" {
oldEndpoint = oldIPv6
}
serviceIPs, _ := instance.FetchServiceAddresses(i.ServiceSnapshot)
for _, serviceIP := range serviceIPs {
if oldEgressIPv6 && !utils.IsIPv6(serviceIP) {
continue
}
oldEndpoint := oldIPv4
if utils.IsIPv6(serviceIP) {
oldEndpoint = oldIPv6
}
if oldEndpoint == "" {
continue
}
if err := egress.Teardown(
oldEndpoint,
serviceIP,
i.ServiceSnapshot.Namespace,
string(i.ServiceSnapshot.UID),
string(i.UID()),
i.ServiceSnapshot.Annotations,
p.config.EgressWithNftables,
); err != nil {
@@ -679,6 +683,7 @@ func (p *Processor) updateEgressConfiguration(ctx context.Context, svc *v1.Servi
}
// upnpLeaseDurationForService determines the UPNP lease duration for a given service, based on its annotations.
// The caller must hold the Service lock when s is a tracked instance.
//
// The default lease duration is set to 1 hour, maintaining the default of 3600 seconds that was previously passed. If
// the service has an annotation of [kubevip.UpnpLeaseDuration], the function attempts to parse its value as a
@@ -738,6 +743,7 @@ func upnpLeaseDurationForService(s *instance.Instance) time.Duration {
// upnpLeaseDurationForService returns a duration that maps to a negative value of seconds or invalid float of seconds,
// it will return the default lease duration in seconds instead. (Technically, it will check for a reasonable range of
// seconds, e.g. ~10 years-ish.)
// The caller must hold the Service lock when s is a tracked instance.
func upnpLeaseDurationForServiceSec(s *instance.Instance) uint32 {
duration := upnpLeaseDurationForService(s)
seconds := duration.Seconds()
@@ -748,8 +754,9 @@ func upnpLeaseDurationForServiceSec(s *instance.Instance) uint32 {
return uint32(defaultUPNPLeaseDuration.Seconds())
}
// Set up UPNP forwards for a service
// We first try to use the more modern Pinhole API introduced in UPNPv2 and fall back to UPNPv2 Port Forwarding if no forward was successful
// upnpMap sets up UPNP forwards for a service. The caller must hold the Service
// lock for s.UID(). It first tries the Pinhole API introduced in UPNPv2 and falls
// back to UPNPv2 port forwarding if no forward was successful.
func (p *Processor) upnpMap(ctx context.Context, s *instance.Instance) {
if !isUPNPEnabled(s.ServiceSnapshot) {
// Skip services missing the annotation
@@ -824,6 +831,8 @@ func (p *Processor) upnpMap(ctx context.Context, s *instance.Instance) {
s.UPNPGatewayIPs = slices.Compact(s.UPNPGatewayIPs)
}
// updateStatus reads and updates tracked instance state. The caller must hold the
// Service lock for i.UID().
func (p *Processor) updateStatus(ctx context.Context, i *instance.Instance) error {
// let's retry status update every 10ms for 30s
retryConfig := wait.Backoff{
@@ -966,22 +975,37 @@ func (p *Processor) RefreshUPNPForwards(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
instances := p.serviceInstances()
// Skip logging if no service instances
if len(p.ServiceInstances) == 0 {
if len(instances) == 0 {
continue
}
log.Info("[UPNP] Refreshing Instances", "number of instances", len(p.ServiceInstances))
for i := range p.ServiceInstances {
p.upnpMap(ctx, p.ServiceInstances[i])
if err := p.updateStatus(ctx, p.ServiceInstances[i]); err != nil {
log.Warn("[UPNP] Error updating service", "ip", p.ServiceInstances[i].ServiceSnapshot.Name, "err", err)
}
log.Info("[UPNP] Refreshing Instances", "number of instances", len(instances))
for _, serviceInstance := range instances {
p.refreshUPNPForward(ctx, serviceInstance)
}
}
}
}
// refreshUPNPForward refreshes one instance if it is still current. It acquires
// the Service lock for serviceInstance.UID; callers must not already hold it.
func (p *Processor) refreshUPNPForward(ctx context.Context, serviceInstance *instance.Instance) {
uid := serviceInstance.UID()
unlockService := p.lockService(uid)
defer unlockService()
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{UID: uid}}
if p.findServiceInstance(service) != serviceInstance {
return
}
p.upnpMap(ctx, serviceInstance)
if err := p.updateStatus(ctx, serviceInstance); err != nil {
log.Warn("[UPNP] Error updating service", "ip", serviceInstance.ServiceSnapshot.Name, "err", err)
}
}
// GenerateLabelFromService generates a label key and value for the given service
func generateLabelsFromService(svc *v1.Service, labelKey string) map[string]string {
addresses, _ := instance.FetchServiceAddresses(svc)

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
@@ -15,9 +16,31 @@ import (
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/node/noop"
"github.com/kube-vip/kube-vip/pkg/vip"
)
func TestConfigureServiceDoesNotOverwriteActiveEndpoint(t *testing.T) {
type testDHCPClient struct {
ips chan string
errors chan error
}
func newTestDHCPClient() *testDHCPClient {
// Unbuffered so a send only completes once the watcher has taken the address.
return &testDHCPClient{ips: make(chan string), errors: make(chan error)}
}
func (c *testDHCPClient) ErrorChannel() chan error { return c.errors }
func (c *testDHCPClient) IPChannel() chan string { return c.ips }
func (c *testDHCPClient) Start(context.Context) error {
return nil
}
func (c *testDHCPClient) Stop() {}
func (c *testDHCPClient) WithHostName(string) vip.DHCPClient {
return c
}
func TestAddServiceDoesNotOverwriteActiveEndpoint(t *testing.T) {
const selectedEndpoint = "172.30.2.40"
staleService := &v1.Service{
@@ -43,12 +66,12 @@ func TestConfigureServiceDoesNotOverwriteActiveEndpoint(t *testing.T) {
t.Errorf("encode Service response: %v", err)
}
case http.MethodPut:
updateRequests++
updatedService := &v1.Service{}
if err := json.NewDecoder(request.Body).Decode(updatedService); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
updateRequests++
currentService = updatedService
if err := json.NewEncoder(writer).Encode(currentService); err != nil {
t.Errorf("encode updated Service response: %v", err)
@@ -59,32 +82,188 @@ func TestConfigureServiceDoesNotOverwriteActiveEndpoint(t *testing.T) {
}))
defer server.Close()
clientSet, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
clientSet, err := kubernetes.NewForConfig(&rest.Config{
Host: server.URL,
ContentConfig: rest.ContentConfig{
ContentType: "application/json",
},
})
if err != nil {
t.Fatalf("create Kubernetes client: %v", err)
}
processor := &Processor{
config: &kubevip.Config{
DisableServiceUpdates: true,
EnableServicesElection: true,
EnableARP: true,
NodeName: "test-node",
},
clientSet: clientSet,
clientSet: clientSet,
nodeLabelManager: noop.NewManager(),
}
serviceInstance := &instance.Instance{ServiceSnapshot: staleService}
if err := processor.configureService(context.Background(), serviceInstance, staleService, &sync.WaitGroup{}); err != nil {
t.Fatalf("configureService returned error: %v", err)
serviceInstance := &instance.Instance{ServiceUID: staleService.UID, ServiceSnapshot: staleService}
processor.ServiceInstances = []*instance.Instance{serviceInstance}
if err := processor.addService(context.Background(), staleService, &sync.WaitGroup{}); err != nil {
t.Fatalf("addService returned error: %v", err)
}
mutex.Lock()
defer mutex.Unlock()
if updateRequests != 0 {
t.Fatalf("configureService sent %d stale Service updates, want none", updateRequests)
if updateRequests != 1 {
t.Fatalf("addService sent %d Service updates, want 1", updateRequests)
}
if got := currentService.Annotations[kubevip.ActiveEndpoint]; got != selectedEndpoint {
t.Fatalf("active endpoint = %q, want %q", got, selectedEndpoint)
}
}
func TestConfigureServiceRejectsCancelledContext(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "cancelled", Namespace: "default", UID: "cancelled",
}}
serviceInstance := &instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service}
processor := &Processor{
config: &kubevip.Config{EnableServicesElection: true},
ServiceInstances: []*instance.Instance{serviceInstance},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := processor.configureService(ctx, serviceInstance, service, &sync.WaitGroup{}); !errors.Is(err, context.Canceled) {
t.Fatalf("configureService() error = %v, want context cancellation", err)
}
}
func TestUpdateEgressConfigurationRejectsRecreatedService(t *testing.T) {
trackedService := &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service", Namespace: "default", UID: "original-uid",
Annotations: map[string]string{kubevip.ActiveEndpoint: "10.0.0.1"},
},
}
updatedService := trackedService.DeepCopy()
updatedService.Annotations[kubevip.ActiveEndpoint] = "10.0.0.2"
recreatedService := updatedService.DeepCopy()
recreatedService.UID = "replacement-uid"
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(writer).Encode(recreatedService); err != nil {
t.Errorf("encode Service response: %v", err)
}
}))
defer server.Close()
clientSet, err := kubernetes.NewForConfig(&rest.Config{
Host: server.URL,
ContentConfig: rest.ContentConfig{
ContentType: "application/json",
},
})
if err != nil {
t.Fatalf("create Kubernetes client: %v", err)
}
snapshot := trackedService.DeepCopy()
serviceInstance := &instance.Instance{ServiceUID: trackedService.UID, ServiceSnapshot: snapshot}
processor := &Processor{
config: &kubevip.Config{},
clientSet: clientSet,
ServiceInstances: []*instance.Instance{serviceInstance},
}
if err := processor.updateEgressConfiguration(context.Background(), updatedService); err != nil {
t.Fatalf("updateEgressConfiguration() error = %v", err)
}
if serviceInstance.ServiceSnapshot != snapshot {
t.Fatal("recreated Service replaced the tracked instance snapshot")
}
}
func TestConfigureServiceWatchesBothDHCPFamilies(t *testing.T) {
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
Name: "test-service", Namespace: "default", UID: "service-uid",
}}
dhcpv4 := newTestDHCPClient()
dhcpv6 := newTestDHCPClient()
serviceInstance := &instance.Instance{
ServiceUID: service.UID,
ServiceSnapshot: service,
VIPConfigs: []*kubevip.Config{
{VIP: "0.0.0.0"},
{VIP: "::"},
},
IsDHCPv4: true,
IsDHCPv6: true,
DHCPv4Client: dhcpv4,
DHCPv6Client: dhcpv6,
}
processor := &Processor{
config: &kubevip.Config{
DisableServiceUpdates: true,
EnableARP: true,
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
EnableLeaderElection: true,
},
},
ServiceInstances: []*instance.Instance{serviceInstance},
nodeLabelManager: noop.NewManager(),
}
wg := &sync.WaitGroup{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := processor.configureService(ctx, serviceInstance, service, wg); err != nil {
t.Fatalf("configureService() error = %v", err)
}
dhcpv4.ips <- "192.0.2.10"
dhcpv6.ips <- "2001:db8::10"
cancel()
wg.Wait()
if got := serviceInstance.DHCPInterfaceIPv4; got != "192.0.2.10" {
t.Fatalf("DHCPInterfaceIPv4 = %q, want %q", got, "192.0.2.10")
}
if got := serviceInstance.DHCPInterfaceIPv6; got != "2001:db8::10" {
t.Fatalf("DHCPInterfaceIPv6 = %q, want %q", got, "2001:db8::10")
}
if got := serviceInstance.VIPConfigs[0].VIP; got != "192.0.2.10" {
t.Fatalf("IPv4 VIP config = %q, want %q", got, "192.0.2.10")
}
if got := serviceInstance.VIPConfigs[1].VIP; got != "2001:db8::10" {
t.Fatalf("IPv6 VIP config = %q, want %q", got, "2001:db8::10")
}
}
func TestServiceSnapshotForEgressUsesCurrentInstanceState(t *testing.T) {
captured := &v1.Service{
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
kubevip.ActiveEndpoint: "10.0.0.1",
kubevip.EgressIPv6: "true",
}},
Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10"},
}
current := captured.DeepCopy()
current.Annotations[kubevip.ActiveEndpoint] = "10.0.0.2"
current.Annotations[kubevip.ActiveEndpointIPv6] = "fd00::2"
serviceInstance := &instance.Instance{ServiceSnapshot: current}
got := serviceSnapshotForEgress(serviceInstance, captured)
if got == captured || got == current {
t.Fatal("egress configuration did not create an isolated merged Service")
}
if got.Annotations[kubevip.ActiveEndpoint] != "10.0.0.2" ||
got.Annotations[kubevip.ActiveEndpointIPv6] != "fd00::2" {
t.Fatalf("merged endpoints = %q, %q", got.Annotations[kubevip.ActiveEndpoint], got.Annotations[kubevip.ActiveEndpointIPv6])
}
if got.Annotations[kubevip.EgressIPv6] != "true" || got.Spec.LoadBalancerIP != "192.0.2.10" {
t.Fatal("merged Service did not preserve current configuration")
}
if got := serviceSnapshotForEgress(nil, captured); got != captured {
t.Fatal("egress configuration did not fall back to the captured Service")
}
}
// Test_upnpLeaseDurationForService tests whether the default lease duration is used, and whether the annotation
// overrides it correctly.
//

View File

@@ -1,170 +0,0 @@
package services
import (
"context"
"fmt"
log "log/slog"
"github.com/kube-vip/kube-vip/pkg/nftables"
"github.com/kube-vip/kube-vip/pkg/utils"
v1 "k8s.io/api/core/v1"
)
// addServiceWireguard configures a WireGuard tunnel for a service
// The tunnel is brought up here, but DNAT rules are configured by the endpoint watcher
// via wireguardWorker.processInstance() when endpoints become available
func (p *Processor) addServiceWireguard(_ context.Context, svc *v1.Service) error {
if !p.config.EnableWireguard {
return nil
}
// Get service VIPs
serviceIPs, err := utils.FetchServiceIPs(svc)
if err != nil {
return fmt.Errorf("failed to get service IPs for %s/%s: %w", svc.Namespace, svc.Name, err)
}
if len(serviceIPs) == 0 {
return fmt.Errorf("no service IPs found for service %s/%s", svc.Namespace, svc.Name)
}
// For each VIP, bring up the WireGuard tunnel
// DNAT rules will be configured by the endpoint watcher when endpoints are available
var successCount int
var lastErr error
for _, vip := range serviceIPs {
if err := p.setupServiceWireguardTunnel(svc, vip); err != nil {
log.Error("[wireguard] failed to setup tunnel for VIP",
"service", svc.Name,
"namespace", svc.Namespace,
"vip", vip,
"err", err)
lastErr = err
// Continue with other VIPs even if one fails
continue
}
successCount++
}
if successCount == 0 {
return fmt.Errorf("failed to setup WireGuard tunnel for any VIP in service %s/%s: %w", svc.Namespace, svc.Name, lastErr)
}
return nil
}
// setupServiceWireguardTunnel brings up the WireGuard tunnel for a single VIP
// DNAT rules are NOT configured here - they are handled by the endpoint watcher
func (p *Processor) setupServiceWireguardTunnel(svc *v1.Service, vip string) error {
// Check if we have a tunnel configuration for this VIP
if !p.TunnelMgr.HasConfigForVIP(vip) {
return fmt.Errorf("no WireGuard tunnel configuration found for VIP %s", vip)
}
// Get the tunnel configuration to determine the interface name
tunnelConfig := p.TunnelMgr.GetConfigForVIP(vip)
if tunnelConfig == nil {
return fmt.Errorf("failed to get tunnel configuration for VIP %s", vip)
}
// Bring up the WireGuard tunnel for this VIP
if err := p.TunnelMgr.BringUpTunnelForVIP(vip); err != nil {
return fmt.Errorf("failed to bring up WireGuard tunnel for VIP %s: %w", vip, err)
}
log.Info("[wireguard] brought up tunnel for service",
"namespace", svc.Namespace,
"name", svc.Name,
"vip", vip,
"interface", tunnelConfig.InterfaceName)
// DNAT rules will be configured by wireguardWorker.processInstance()
// when the endpoint watcher detects available endpoints
return nil
}
// deleteServiceWireguard removes nftables DNAT rules and tears down WireGuard tunnel for a service
func (p *Processor) deleteServiceWireguard(_ context.Context, svc *v1.Service) {
if !p.config.EnableWireguard {
return
}
serviceID := fmt.Sprintf("%s_%s", svc.Namespace, svc.Name)
serviceID = utils.SanitizeServiceID(serviceID)
log.Info("[wireguard] deleting DNAT rules and tunnel for service",
"namespace", svc.Namespace,
"name", svc.Name,
"serviceID", serviceID)
// Get service IPs
serviceIPs, _ := utils.FetchServiceIPs(svc)
// Delete DNAT chains for each port
for _, port := range svc.Spec.Ports {
if port.Protocol != v1.ProtocolTCP && port.Protocol != v1.ProtocolUDP {
continue
}
portServiceID := fmt.Sprintf("%s_p%d", serviceID, port.Port)
// Try to delete for both IPv4 and IPv6 if we have mixed IPs
hasIPv4 := false
hasIPv6 := false
for _, vip := range serviceIPs {
// Strip CIDR notation before checking IP version
addr := utils.StripCIDR(vip)
if utils.IsIPv6(addr) {
hasIPv6 = true
} else {
hasIPv4 = true
}
}
if hasIPv4 {
if err := nftables.DeleteIngressChains(false, portServiceID); err != nil {
log.Error("[wireguard] failed to delete IPv4 DNAT chains",
"service", svc.Name,
"port", port.Port,
"err", err)
} else {
log.Debug("[wireguard] deleted IPv4 DNAT chains",
"service", svc.Name,
"port", port.Port)
}
}
if hasIPv6 {
if err := nftables.DeleteIngressChains(true, portServiceID); err != nil {
log.Error("[wireguard] failed to delete IPv6 DNAT chains",
"service", svc.Name,
"port", port.Port,
"err", err)
} else {
log.Debug("[wireguard] deleted IPv6 DNAT chains",
"service", svc.Name,
"port", port.Port)
}
}
}
// Tear down the WireGuard tunnel for each VIP
for _, vip := range serviceIPs {
if err := p.TunnelMgr.TearDownTunnelForVIP(vip); err != nil {
log.Error("[wireguard] failed to tear down tunnel",
"service", svc.Name,
"vip", vip,
"err", err)
} else {
log.Info("[wireguard] tore down tunnel",
"service", svc.Name,
"vip", vip)
}
}
log.Info("[wireguard] DNAT rules deleted and tunnels torn down for service",
"namespace", svc.Namespace,
"name", svc.Name)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/kube-vip/kube-vip/pkg/debouncer"
"github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/utils"
v1 "k8s.io/api/core/v1"
@@ -66,7 +67,7 @@ func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, ser
}
})
epProcessor := endpoints.NewEndpointProcessor(p.config, provider, p.bgpServer, &p.ServiceInstances, p.leaseMgr, p.TunnelMgr, p.routeMgr)
epProcessor := endpoints.NewEndpointProcessor(p.config, provider, p.bgpServer, &p.ServiceInstances, &p.instancesMutex, p.leaseMgr, p.TunnelMgr, p.routeMgr, p.lockService)
ch := rw.ResultChan()
if d != nil {
@@ -84,11 +85,19 @@ func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, ser
}
restart, err := epProcessor.Reconcile(svcCtx, event, &lastKnownGoodEndpoint, service, id,
p.StartServicesLeaderElection, &wg, p.clientSet, p.updateEgressConfiguration)
&wg, p.clientSet, func(ctx context.Context, service *v1.Service, inst *instance.Instance) error {
return p.updateEgressConfiguration(ctx, service, inst)
})
if restart {
continue
} else if err != nil {
return fmt.Errorf("[%s] error while processing %s event: %w", provider.GetLabel(), event.Type, err)
}
if err != nil {
eventErr := fmt.Errorf("[%s] error while processing %s event: %w", provider.GetLabel(), event.Type, err)
if utils.IsPanicError(err) {
return eventErr
}
log.Error("endpoint watcher event failed", "provider", provider.GetLabel(), "type", event.Type, "error", eventErr)
continue
}
case watch.Error:

View File

@@ -2,8 +2,10 @@ package services
import (
"context"
"errors"
"fmt"
"sync"
"time"
log "log/slog"
@@ -15,11 +17,119 @@ import (
"github.com/prometheus/client_golang/prometheus"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/tools/cache"
watchtools "k8s.io/client-go/tools/watch"
"k8s.io/client-go/util/workqueue"
)
const concurrentServiceEventWorkers = 4
const serviceAddressRetryDelay = time.Second
type serviceEventTask struct {
uid types.UID
run func() time.Duration
}
type serviceEventQueue struct {
ctx context.Context
queue workqueue.TypedDelayingInterface[types.NamespacedName]
mutex sync.Mutex
tasks map[types.NamespacedName][]*serviceEventTask
wg sync.WaitGroup
}
func newServiceEventQueue(ctx context.Context, workers int) *serviceEventQueue {
q := &serviceEventQueue{
ctx: ctx,
queue: workqueue.NewTypedDelayingQueue[types.NamespacedName](),
tasks: make(map[types.NamespacedName][]*serviceEventTask),
}
for range workers {
q.wg.Go(q.run)
}
return q
}
func (q *serviceEventQueue) Add(key types.NamespacedName, uid types.UID, run func() time.Duration) {
q.mutex.Lock()
defer q.mutex.Unlock()
if q.ctx.Err() != nil || q.queue.ShuttingDown() {
return
}
tasks := q.tasks[key]
task := &serviceEventTask{uid: uid, run: run}
if len(tasks) != 0 && tasks[len(tasks)-1].uid == uid {
tasks[len(tasks)-1] = task
} else {
tasks = append(tasks, task)
}
q.tasks[key] = tasks
q.queue.Add(key)
}
func (q *serviceEventQueue) run() {
for {
key, shutdown := q.queue.Get()
if shutdown {
return
}
q.runNext(key)
}
}
func (q *serviceEventQueue) runNext(key types.NamespacedName) {
defer q.queue.Done(key)
for {
task := q.nextTask(key)
if task == nil {
return
}
if q.ctx.Err() == nil {
if retryAfter := task.run(); retryAfter > 0 {
q.retry(key, task, retryAfter)
return
}
}
}
}
func (q *serviceEventQueue) retry(key types.NamespacedName, task *serviceEventTask, retryAfter time.Duration) {
q.mutex.Lock()
defer q.mutex.Unlock()
if q.ctx.Err() != nil || q.queue.ShuttingDown() || len(q.tasks[key]) != 0 {
return
}
q.tasks[key] = []*serviceEventTask{task}
q.queue.AddAfter(key, retryAfter)
}
func (q *serviceEventQueue) nextTask(key types.NamespacedName) *serviceEventTask {
q.mutex.Lock()
defer q.mutex.Unlock()
tasks := q.tasks[key]
if len(tasks) == 0 {
delete(q.tasks, key)
return nil
}
task := tasks[0]
if len(tasks) == 1 {
delete(q.tasks, key)
} else {
q.tasks[key] = tasks[1:]
}
return task
}
func (q *serviceEventQueue) Wait() {
q.queue.ShutDown()
q.wg.Wait()
q.mutex.Lock()
defer q.mutex.Unlock()
clear(q.tasks)
}
// This function handles the watching of a services endpoints and updates a load balancers endpoint configurations accordingly
func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback, forcedOnly bool) error {
// first start port mirroring if enabled
@@ -41,6 +151,9 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
} else {
log.Info("(svcs) starting services watcher", "namespace", p.config.ServiceNamespace)
}
if err := p.RecoverAddresses(ctx); err != nil {
log.Warn("skipping kube-vip address recovery", "err", err)
}
// Use a restartable watcher, as this should help in the event of etcd or timeout issues
rw, err := watchtools.NewRetryWatcherWithContext(ctx, "1", &cache.ListWatch{
@@ -60,15 +173,16 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
}
var wg sync.WaitGroup
watcherCtx, cancelWatcher := context.WithCancelCause(ctx)
eventQueue := newServiceEventQueue(watcherCtx, concurrentServiceEventWorkers)
defer func() {
if d != nil {
d.Stop()
}
rw.Stop()
eventQueue.Wait()
wg.Wait()
}()
watcherCtx, cancelWatcher := context.WithCancelCause(ctx)
defer cancelWatcher(nil)
wg.Go(func() {
@@ -93,41 +207,40 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
}
// Used for tracking an active endpoint / pod
EventLoop:
for event := range ch {
metrics.CountServiceWatchEvent.With(prometheus.Labels{"type": string(event.Type)}).Add(1)
select {
case <-ctx.Done():
log.Info("global context done")
case <-watcherCtx.Done():
log.Info("WatcheConotext done")
break EventLoop
default:
// We need to inspect the event and get ResourceVersion out of it
switch event.Type {
case watch.Added, watch.Modified:
if err := p.AddOrModify(watcherCtx, event, serviceFunc, forcedOnly, &wg, cancelWatcher); err != nil {
if utils.IsPanicError(err) {
return fmt.Errorf("add/modify service error: %w", err)
}
log.Error("service watcher event failed", "type", event.Type, "error", err)
}
case watch.Deleted:
if err := p.Delete(event, forcedOnly); err != nil {
if utils.IsPanicError(err) {
return fmt.Errorf("delete service error: %w", err)
}
log.Error("service watcher event failed", "type", event.Type, "error", err)
}
case watch.Bookmark:
// Un-used
case watch.Error:
log.Error("Error attempting to watch Kubernetes services")
watchErr := utils.WatchError(event.Object)
log.Error("services", "err", watchErr)
return utils.WrapPanicError(watchErr, "service watch failed")
default:
switch event.Type {
case watch.Added, watch.Modified, watch.Deleted:
svc, ok := event.Object.(*v1.Service)
if !ok || svc == nil {
log.Error("service watcher event failed", "type", event.Type, "error", "unable to parse Kubernetes Service")
continue
}
if event.Type == watch.Deleted && serviceMatchesWatcher(svc, forcedOnly) {
if _, err := p.cancelPublishedServiceContext(svc.UID); err != nil {
log.Error("failed to cancel deleted Service context", "service", svc.Name, "namespace", svc.Namespace, "error", err)
}
}
event := event
key := types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}
eventQueue.Add(key, svc.UID, func() time.Duration {
if err := p.processServiceEvent(watcherCtx, event, serviceFunc, forcedOnly, &wg, cancelWatcher); err != nil {
if errors.Is(err, errServiceAddressPending) {
return serviceAddressRetryDelay
}
cancelWatcher(err)
}
return 0
})
case watch.Bookmark:
// Un-used
case watch.Error:
log.Error("Error attempting to watch Kubernetes services")
watchErr := utils.WatchError(event.Object)
log.Error("services", "err", watchErr)
return utils.WrapPanicError(watchErr, "service watch failed")
default:
}
}
@@ -141,6 +254,30 @@ EventLoop:
return utils.NewPanicError("service watch channel closed unexpectedly")
}
func (p *Processor) processServiceEvent(ctx context.Context, event watch.Event, serviceFunc *Callback, forcedOnly bool,
wg *sync.WaitGroup, cancelWatcher context.CancelCauseFunc) error {
var err error
switch event.Type {
case watch.Added, watch.Modified:
err = p.Reconcile(ctx, event, serviceFunc, forcedOnly, wg, cancelWatcher)
if utils.IsPanicError(err) {
return fmt.Errorf("reconcile service error: %w", err)
}
case watch.Deleted:
err = p.Delete(event, forcedOnly)
if utils.IsPanicError(err) {
return fmt.Errorf("delete service error: %w", err)
}
}
if err != nil {
if errors.Is(err, errServiceAddressPending) {
return err
}
log.Error("service watcher event failed", "type", event.Type, "error", err)
}
return nil
}
func lbClassFilterLegacy(svc *v1.Service, config *kubevip.Config) bool {
if svc == nil {
log.Info("(svcs) service is nil, ignoring")

View File

@@ -3,12 +3,17 @@ package services
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/utils"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
)
@@ -97,3 +102,136 @@ func TestWatchWithAuthRetry(t *testing.T) {
})
}
}
func TestServiceEventQueuePreservesOrderPerUID(t *testing.T) {
queue := newServiceEventQueue(context.Background(), 2)
key := types.NamespacedName{Namespace: "default", Name: "service"}
releaseFirst := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseFirst) }) })
order := make(chan int, 2)
firstStarted := make(chan struct{})
queue.Add(key, types.UID("service"), func() time.Duration {
close(firstStarted)
<-releaseFirst
order <- 1
return 0
})
<-firstStarted
queue.Add(key, types.UID("service"), func() time.Duration {
order <- 2
return 0
})
releaseOnce.Do(func() { close(releaseFirst) })
queue.Wait()
if first, second := <-order, <-order; first != 1 || second != 2 {
t.Fatalf("execution order = [%d %d], want [1 2]", first, second)
}
}
func TestServiceEventQueueRunsDifferentUIDsConcurrently(t *testing.T) {
queue := newServiceEventQueue(context.Background(), 2)
releaseFirst := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseFirst) }) })
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
queue.Add(types.NamespacedName{Namespace: "default", Name: "first"}, types.UID("first"), func() time.Duration {
close(firstStarted)
<-releaseFirst
return 0
})
<-firstStarted
queue.Add(types.NamespacedName{Namespace: "default", Name: "second"}, types.UID("second"), func() time.Duration {
close(secondStarted)
return 0
})
select {
case <-secondStarted:
case <-time.After(time.Second):
t.Fatal("unrelated Service event waited for the blocked Service")
}
releaseOnce.Do(func() { close(releaseFirst) })
queue.Wait()
}
func TestServiceEventQueueCoalescesPendingUpdates(t *testing.T) {
queue := newServiceEventQueue(context.Background(), 0)
key := types.NamespacedName{Namespace: "default", Name: "service"}
ran := ""
queue.Add(key, types.UID("service"), func() time.Duration { ran = "first"; return 0 })
queue.Add(key, types.UID("service"), func() time.Duration { ran = "second"; return 0 })
queue.wg.Go(queue.run)
queue.Wait()
if ran != "second" {
t.Fatalf("pending update result = %q, want latest update", ran)
}
}
func TestServiceEventQueueOrdersDeleteAndRecreateByName(t *testing.T) {
queue := newServiceEventQueue(context.Background(), 2)
key := types.NamespacedName{Namespace: "default", Name: "service"}
releaseDelete := make(chan struct{})
deleteStarted := make(chan struct{})
addStarted := make(chan struct{})
order := make(chan string, 2)
queue.Add(key, types.UID("old"), func() time.Duration {
close(deleteStarted)
<-releaseDelete
order <- "delete"
return 0
})
<-deleteStarted
queue.Add(key, types.UID("new"), func() time.Duration {
close(addStarted)
order <- "add"
return 0
})
select {
case <-addStarted:
t.Fatal("recreated Service started before deletion finished")
case <-time.After(20 * time.Millisecond):
}
close(releaseDelete)
queue.Wait()
if first, second := <-order, <-order; first != "delete" || second != "add" {
t.Fatalf("execution order = [%s %s], want [delete add]", first, second)
}
}
func TestServiceEventQueueDelayedTasksDoNotStarveWorkers(t *testing.T) {
queue := newServiceEventQueue(context.Background(), concurrentServiceEventWorkers)
for index := range concurrentServiceEventWorkers {
key := types.NamespacedName{Namespace: "default", Name: fmt.Sprintf("pending-%d", index)}
queue.Add(key, types.UID(key.Name), func() time.Duration { return time.Hour })
}
run := make(chan struct{})
queue.Add(types.NamespacedName{Namespace: "default", Name: "ready"}, types.UID("ready"), func() time.Duration {
close(run)
return 0
})
select {
case <-run:
case <-time.After(time.Second):
t.Fatal("delayed address tasks starved an unrelated Service event")
}
queue.Wait()
}
func TestServiceMatchesWatcher(t *testing.T) {
regular := &v1.Service{}
forced := &v1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.ForcePerServiceElection: "true"}}}
if !serviceMatchesWatcher(regular, false) || serviceMatchesWatcher(regular, true) {
t.Fatal("regular Service watcher ownership is incorrect")
}
if !serviceMatchesWatcher(forced, true) || serviceMatchesWatcher(forced, false) {
t.Fatal("forced-election Service watcher ownership is incorrect")
}
}

View File

@@ -32,18 +32,18 @@ type TunnelConfig struct {
// TunnelManager manages multiple WireGuard tunnels
type TunnelManager struct {
mu sync.RWMutex
tunnels map[string]*WireGuard // key: VIP (without CIDR), value: WireGuard instance
configs map[string]*TunnelConfig // key: VIP (without CIDR), value: TunnelConfig
refCount map[string]int // key: VIP (without CIDR), value: number of consumers using the tunnel
mu sync.RWMutex
tunnels map[string]*WireGuard // key: VIP (without CIDR), value: WireGuard instance
configs map[string]*TunnelConfig // key: VIP (without CIDR), value: TunnelConfig
owners map[string]map[string]struct{} // key: VIP (without CIDR), value: idempotent consumer claims
}
// NewTunnelManager creates a new tunnel manager
func NewTunnelManager() *TunnelManager {
return &TunnelManager{
tunnels: make(map[string]*WireGuard),
configs: make(map[string]*TunnelConfig),
refCount: make(map[string]int),
tunnels: make(map[string]*WireGuard),
configs: make(map[string]*TunnelConfig),
owners: make(map[string]map[string]struct{}),
}
}
@@ -167,6 +167,9 @@ func (tm *TunnelManager) parseTunnelConfig(data []byte) error {
// addTunnelConfig adds a tunnel configuration to the manager
func (tm *TunnelManager) addTunnelConfig(config *TunnelConfig) error {
if tm.configs == nil {
tm.configs = make(map[string]*TunnelConfig)
}
// Validate required fields
if config.VIP == "" {
return fmt.Errorf("vip is required")
@@ -221,19 +224,38 @@ func (tm *TunnelManager) GetConfigForVIP(vip string) *TunnelConfig {
return tm.configs[vipKey]
}
// BringUpTunnelForVIP creates and brings up a WireGuard tunnel for the given VIP.
// If the tunnel is already up, it increments the reference count.
// Multiple consumers (control plane, services) can share the same VIP tunnel.
func (tm *TunnelManager) BringUpTunnelForVIP(vip string) error {
// AcquireTunnelForVIP brings up a tunnel and records one idempotent owner claim.
// Repeated acquisition by the same owner does not increase the reference count.
func (tm *TunnelManager) AcquireTunnelForVIP(vip, owner string) error {
if owner == "" {
return fmt.Errorf("tunnel owner is required for VIP %s", vip)
}
tm.mu.Lock()
defer tm.mu.Unlock()
vipKey := utils.StripCIDR(vip)
if _, exists := tm.owners[vipKey][owner]; exists {
return nil
}
if err := tm.ensureTunnelForVIPLocked(vip); err != nil {
return err
}
if tm.owners == nil {
tm.owners = make(map[string]map[string]struct{})
}
if tm.owners[vipKey] == nil {
tm.owners[vipKey] = make(map[string]struct{})
}
tm.owners[vipKey][owner] = struct{}{}
return nil
}
// Check if already up - increment reference count
func (tm *TunnelManager) ensureTunnelForVIPLocked(vip string) error {
vipKey := utils.StripCIDR(vip)
// The tunnel resource is shared by all anonymous and named consumers.
if _, exists := tm.tunnels[vipKey]; exists {
tm.refCount[vipKey]++
log.Debug("tunnel already up, incremented reference count", "vip", vip, "refCount", tm.refCount[vipKey])
return nil
}
@@ -269,7 +291,6 @@ func (tm *TunnelManager) BringUpTunnelForVIP(vip string) error {
}
tm.tunnels[vipKey] = wg
tm.refCount[vipKey] = 1
log.Info("brought up WireGuard tunnel",
"vip", vip,
"interface", config.InterfaceName,
@@ -278,26 +299,40 @@ func (tm *TunnelManager) BringUpTunnelForVIP(vip string) error {
return nil
}
// TearDownTunnelForVIP decrements the reference count for the given VIP tunnel.
// The tunnel is only torn down when the reference count reaches zero.
// This allows multiple consumers (control plane, services) to share the same VIP tunnel.
func (tm *TunnelManager) TearDownTunnelForVIP(vip string) error {
// ReleaseTunnelForVIP removes one owner claim. Unknown and already released
// owners are harmless; the tunnel is torn down after the final consumer leaves.
func (tm *TunnelManager) ReleaseTunnelForVIP(vip, owner string) error {
if owner == "" {
return fmt.Errorf("tunnel owner is required for VIP %s", vip)
}
tm.mu.Lock()
defer tm.mu.Unlock()
vipKey := utils.StripCIDR(vip)
owners := tm.owners[vipKey]
if _, exists := owners[owner]; !exists {
return nil
}
delete(owners, owner)
if len(owners) == 0 {
delete(tm.owners, vipKey)
}
tm.tearDownTunnelIfUnusedLocked(vip)
return nil
}
func (tm *TunnelManager) tearDownTunnelIfUnusedLocked(vip string) {
vipKey := utils.StripCIDR(vip)
if len(tm.owners[vipKey]) != 0 {
log.Debug("tunnel still in use", "vip", vip, "refCount", tm.refCountLocked(vipKey))
return
}
wg, exists := tm.tunnels[vipKey]
if !exists {
log.Debug("tunnel not found for teardown", "vip", vip)
return nil
}
// Decrement reference count
tm.refCount[vipKey]--
if tm.refCount[vipKey] > 0 {
log.Debug("tunnel still in use, decremented reference count", "vip", vip, "refCount", tm.refCount[vipKey])
return nil
return
}
// Get config before cleanup (need interface name and listen port)
@@ -324,10 +359,7 @@ func (tm *TunnelManager) TearDownTunnelForVIP(vip string) error {
}
delete(tm.tunnels, vipKey)
delete(tm.refCount, vipKey)
log.Info("tore down WireGuard tunnel", "vip", vip)
return nil
}
// TearDownAllTunnels tears down all active tunnels regardless of reference count.
@@ -360,7 +392,7 @@ func (tm *TunnelManager) TearDownAllTunnels() error {
}
tm.tunnels = make(map[string]*WireGuard)
tm.refCount = make(map[string]int)
tm.owners = make(map[string]map[string]struct{})
log.Info("tore down all WireGuard tunnels")
if len(errors) > 0 {
@@ -377,7 +409,11 @@ func (tm *TunnelManager) GetRefCount(vip string) int {
defer tm.mu.RUnlock()
vipKey := utils.StripCIDR(vip)
return tm.refCount[vipKey]
return tm.refCountLocked(vipKey)
}
func (tm *TunnelManager) refCountLocked(vipKey string) int {
return len(tm.owners[vipKey])
}
// ListActiveTunnels returns a list of VIPs with active tunnels

View File

@@ -0,0 +1,86 @@
package wireguard
import "testing"
func TestTunnelManagerAcquireIsIdempotentForOwner(t *testing.T) {
const vip = "192.0.2.10"
manager := newSeededTunnelManager()
for range 3 {
if err := manager.AcquireTunnelForVIP(vip, "service-a"); err != nil {
t.Fatalf("AcquireTunnelForVIP() error = %v", err)
}
}
if got := manager.GetRefCount(vip); got != 1 {
t.Fatalf("reference count after repeated acquire = %d, want 1", got)
}
if err := manager.ReleaseTunnelForVIP(vip, "service-a"); err != nil {
t.Fatalf("ReleaseTunnelForVIP() error = %v", err)
}
if got := manager.GetRefCount(vip); got != 0 {
t.Fatalf("reference count after release = %d, want 0", got)
}
if manager.GetTunnelForVIP(vip) != nil {
t.Fatal("tunnel remained active after its only owner released it")
}
}
func TestTunnelManagerRetainsSharedVIPUntilFinalOwnerReleased(t *testing.T) {
const vip = "192.0.2.10"
manager := newSeededTunnelManager()
if err := manager.AcquireTunnelForVIP(vip, "service-a"); err != nil {
t.Fatalf("first AcquireTunnelForVIP() error = %v", err)
}
if err := manager.AcquireTunnelForVIP(vip, "service-b"); err != nil {
t.Fatalf("second AcquireTunnelForVIP() error = %v", err)
}
if got := manager.GetRefCount(vip); got != 2 {
t.Fatalf("shared reference count = %d, want 2", got)
}
if err := manager.ReleaseTunnelForVIP(vip, "service-a"); err != nil {
t.Fatalf("first ReleaseTunnelForVIP() error = %v", err)
}
if got := manager.GetRefCount(vip); got != 1 {
t.Fatalf("reference count after first release = %d, want 1", got)
}
if manager.GetTunnelForVIP(vip) == nil {
t.Fatal("shared tunnel was removed while one owner remained")
}
if err := manager.ReleaseTunnelForVIP(vip, "service-b"); err != nil {
t.Fatalf("second ReleaseTunnelForVIP() error = %v", err)
}
if got := manager.GetRefCount(vip); got != 0 {
t.Fatalf("reference count after final release = %d, want 0", got)
}
if manager.GetTunnelForVIP(vip) != nil {
t.Fatal("shared tunnel remained active after its final owner released it")
}
}
func TestTunnelManagerIgnoresUnknownOwnerRelease(t *testing.T) {
const vip = "192.0.2.10"
manager := newSeededTunnelManager()
if err := manager.AcquireTunnelForVIP(vip, "current-service"); err != nil {
t.Fatalf("AcquireTunnelForVIP() error = %v", err)
}
if err := manager.ReleaseTunnelForVIP(vip, "stale-service"); err != nil {
t.Fatalf("ReleaseTunnelForVIP() error = %v", err)
}
if got := manager.GetRefCount(vip); got != 1 {
t.Fatalf("reference count after stale release = %d, want 1", got)
}
if manager.GetTunnelForVIP(vip) == nil {
t.Fatal("stale owner release removed the current owner's tunnel")
}
}
func newSeededTunnelManager() *TunnelManager {
manager := NewTunnelManager()
manager.tunnels["192.0.2.10"] = NewWireGuard(WGConfig{InterfaceName: "kube-vip-test-missing"})
return manager
}