mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/kube-vip/kube-vip.git
synced 2026-09-20 08:03:47 +08:00
Compare commits
24 Commits
57cc16ec0d
...
711adae03e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711adae03e | ||
|
|
acd87f465d | ||
|
|
b514ae2733 | ||
|
|
26eab74f3e | ||
|
|
44a67bc901 | ||
|
|
7df2e5dd46 | ||
|
|
ba8ffde3c8 | ||
|
|
65a6a6f8f2 | ||
|
|
94ff8495f4 | ||
|
|
52021d9232 | ||
|
|
776f0b18fa | ||
|
|
86ba1d09af | ||
|
|
6512709032 | ||
|
|
1196748efc | ||
|
|
d557211b55 | ||
|
|
e4eb5e8e7f | ||
|
|
a7b068f9d2 | ||
|
|
a6bf5280d4 | ||
|
|
61a52e8f2a | ||
|
|
7e2dd0d262 | ||
|
|
7a085cb3ce | ||
|
|
1f5c135fd1 | ||
|
|
b4771c5319 | ||
|
|
3771ccee29 |
13
.github/suggestion-comment.md
vendored
Normal file
13
.github/suggestion-comment.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
I'll help you add a suggestion. Unfortunately, I can't directly add a suggestion to an existing comment through the API. However, here's what I recommend:
|
||||
|
||||
**Option 1: Reply with a suggestion**
|
||||
Create a new comment with a suggested fix:
|
||||
|
||||
```suggestion
|
||||
failed to get an IPv6 address after %d attempt(s), giving up, error: %s
|
||||
```
|
||||
|
||||
**Option 2: Edit your existing comment**
|
||||
Update your comment to include the suggestion details pointing out that line 284 in the error message says "IPv4" but should say "IPv6" since this is the DHCPv6Client.
|
||||
|
||||
Would you like me to create a new reply comment with the suggestion instead?
|
||||
2
.github/workflows/anchore-syft.yml
vendored
2
.github/workflows/anchore-syft.yml
vendored
@@ -26,6 +26,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
- name: Anchore SBOM Action
|
||||
uses: anchore/sbom-action@v0.24.0
|
||||
uses: anchore/sbom-action@v0.24.2
|
||||
with:
|
||||
format: cyclonedx-json
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -51,6 +51,10 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: make unit-tests
|
||||
- name: Run privileged network tests
|
||||
run: |
|
||||
sudo -E env PATH="$PATH" KUBE_VIP_REQUIRE_NETNS=1 go test -race ./pkg/services ./pkg/vip ./pkg/instance \
|
||||
-run 'TestRecover|TestServiceAddressRetained|TestRetainControlPlaneVIPs|TestAddressProtocol|TestKubeVIPAddressProtocol|TestCleanupKubeVIPAddresses|TestMonitorDefaultInterfaceReturnsErrorWhenTestLinkIsSetDown|TestCleanupLinkAttachmentsOnlyDeletesOwnedVLAN'
|
||||
integration-tests:
|
||||
name: Integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
12
go.mod
12
go.mod
@@ -15,21 +15,22 @@ require (
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
|
||||
github.com/jpillora/backoff v1.0.0
|
||||
github.com/mdlayher/ndp v1.1.0
|
||||
github.com/onsi/ginkgo/v2 v2.32.1
|
||||
github.com/onsi/ginkgo/v2 v2.32.2
|
||||
github.com/onsi/gomega v1.43.0
|
||||
github.com/osrg/gobgp/v4 v4.9.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
github.com/vishvananda/netlink v1.3.2-0.20260830232854-cf01b55a4a4b
|
||||
github.com/vishvananda/netns v0.0.5
|
||||
go.etcd.io/etcd/api/v3 v3.7.1
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1
|
||||
go.etcd.io/etcd/client/v3 v3.7.1
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
google.golang.org/grpc v1.83.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -37,6 +38,7 @@ require (
|
||||
k8s.io/apimachinery v0.36.4
|
||||
k8s.io/client-go v0.36.4
|
||||
k8s.io/klog/v2 v2.140.0
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
|
||||
sigs.k8s.io/kind v0.33.0
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
@@ -120,7 +122,6 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -147,7 +148,6 @@ require (
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
|
||||
|
||||
18
go.sum
18
go.sum
@@ -192,8 +192,8 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
|
||||
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -265,8 +265,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netlink v1.3.2-0.20260830232854-cf01b55a4a4b h1:XtEhFJO3IqjQWHJZ3bbNm7LtbDehriJK65KW+6lnw+Q=
|
||||
github.com/vishvananda/netlink v1.3.2-0.20260830232854-cf01b55a4a4b/go.mod h1:lEui7SPMd9fgxzHVGRAvTxsBGCF6PRH81o2kLWLWHgw=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -325,17 +325,15 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
|
||||
114
pkg/arp/arp.go
114
pkg/arp/arp.go
@@ -13,15 +13,17 @@ import (
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
const linkSubscriptionBuffer = 64
|
||||
|
||||
type Manager struct {
|
||||
instances sync.Map
|
||||
mu sync.Mutex
|
||||
instances map[string]*Instance
|
||||
config *kubevip.Config
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
network vip.Network
|
||||
ndp *vip.NdpResponder
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
}
|
||||
|
||||
@@ -31,7 +33,8 @@ func NewManager(config *kubevip.Config) *Manager {
|
||||
config.ArpBroadcastRate = 3000
|
||||
}
|
||||
return &Manager{
|
||||
config: config,
|
||||
instances: make(map[string]*Instance),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,19 +51,16 @@ func (i *Instance) Name() string {
|
||||
}
|
||||
|
||||
func (m *Manager) Insert(instance *Instance) {
|
||||
i, err := m.get(instance.Name())
|
||||
if err != nil {
|
||||
log.Error("[ARP manager] unable to insert instance", "err", err)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
existing := m.instances[instance.Name()]
|
||||
if existing == nil {
|
||||
m.instances[instance.Name()] = instance
|
||||
log.Info("[ARP manager] inserting ARP/NDP instance", "name", instance.Name())
|
||||
return
|
||||
}
|
||||
if i == nil {
|
||||
log.Info("[ARP manager] inserting ARP/NDP instance", "name", instance.Name())
|
||||
m.instances.Store(instance.Name(), instance)
|
||||
} else {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
i.counter++
|
||||
}
|
||||
existing.counter++
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(instance *Instance) {
|
||||
@@ -77,14 +77,11 @@ func (m *Manager) RemoveOnLeadershipLoss(instance *Instance) {
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveWithIPDelete(instance *Instance, deleteIP bool) {
|
||||
i, err := m.get(instance.Name())
|
||||
if err != nil {
|
||||
log.Error("[ARP manager] unable to remove the instance", "err", err)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
i := m.instances[instance.Name()]
|
||||
if i != nil {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
i.counter--
|
||||
if i.counter == 0 {
|
||||
log.Info("[ARP manager] removing ARP/NDP instance", "name", instance.Name())
|
||||
@@ -93,7 +90,7 @@ func (m *Manager) RemoveWithIPDelete(instance *Instance, deleteIP bool) {
|
||||
log.Error("failed to delete IP", "address", instance.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
m.instances.Delete(instance.Name())
|
||||
delete(m.instances, instance.Name())
|
||||
}
|
||||
} else {
|
||||
log.Warn("[ARP manager] unable to remove the instance - instance not found", "name", instance.Name())
|
||||
@@ -101,14 +98,11 @@ func (m *Manager) RemoveWithIPDelete(instance *Instance, deleteIP bool) {
|
||||
}
|
||||
|
||||
func (m *Manager) Count(name string) int {
|
||||
i, err := m.get(name)
|
||||
if err != nil {
|
||||
log.Error("[ARP manager] unable to count instance", "err", err)
|
||||
return -1
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
i := m.instances[name]
|
||||
if i != nil {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
return i.counter
|
||||
}
|
||||
return 0
|
||||
@@ -158,35 +152,22 @@ func (m *Manager) StartAdvertisement(ctx context.Context, killFunc func()) {
|
||||
case <-ctx.Done(): // if cancel() execute
|
||||
return
|
||||
case <-ticker.C: // send gratuitous ARP/NDP on each tick
|
||||
m.instances.Range(func(_ any, instance any) bool {
|
||||
if i, ok := instance.(*Instance); ok {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
if i.counter > 0 {
|
||||
ensureIPAndSendGratuitous(i)
|
||||
} else {
|
||||
// this instance should not be advertised - delete the IP just in case...
|
||||
if _, err := i.network.DeleteIP(); err != nil {
|
||||
log.Error("[ARP manager] failed to delete IP", "address", i.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
m.advertiseAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) get(name string) (*Instance, error) {
|
||||
i, exists := m.instances.Load(name)
|
||||
if !exists {
|
||||
return nil, nil
|
||||
func (m *Manager) advertiseAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, instance := range m.instances {
|
||||
if instance.counter > 0 {
|
||||
ensureIPAndSendGratuitous(instance)
|
||||
} else if _, err := instance.network.DeleteIP(); err != nil {
|
||||
log.Error("[ARP manager] failed to delete IP", "address", instance.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
inst, ok := i.(*Instance)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("value for name %q is not of Instance pointer type", name)
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// ensureIPAndSendGratuitous - adds IP to the interface if missing, and send
|
||||
@@ -255,13 +236,18 @@ func watch(ctx context.Context, interfaceName string, operStateHandler func(netl
|
||||
return fmt.Errorf("interface %s is not physical, ignoring", interfaceName)
|
||||
}
|
||||
|
||||
events := make(chan netlink.LinkUpdate)
|
||||
// The subscription is buffered and drained on exit: netlink parks its reader
|
||||
// goroutine on an unread send, which closing done alone does not release.
|
||||
events := make(chan netlink.LinkUpdate, linkSubscriptionBuffer)
|
||||
done := make(chan struct{})
|
||||
|
||||
if err := netlink.LinkSubscribe(events, done); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to the interface events: %w", err)
|
||||
}
|
||||
defer close(done)
|
||||
defer func() {
|
||||
close(done)
|
||||
drainLinkUpdates(events)
|
||||
}()
|
||||
|
||||
// handle initial state
|
||||
operStateHandler(ifname.Attrs().OperState)
|
||||
@@ -290,3 +276,21 @@ func watch(ctx context.Context, interfaceName string, operStateHandler func(netl
|
||||
func isUp(operState netlink.LinkOperState) bool {
|
||||
return operState == netlink.OperUp
|
||||
}
|
||||
|
||||
// drainLinkUpdates releases a netlink sender that is parked on an unread update
|
||||
// so its goroutine can observe the closed subscription and exit.
|
||||
func drainLinkUpdates(events <-chan netlink.LinkUpdate) {
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
pkg/arp/arp_test.go
Normal file
119
pkg/arp/arp_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package arp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/vishvananda/netlink"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestDrainLinkUpdatesReleasesParkedSender(t *testing.T) {
|
||||
events := make(chan netlink.LinkUpdate)
|
||||
sent := make(chan struct{})
|
||||
go func() {
|
||||
events <- netlink.LinkUpdate{}
|
||||
close(sent)
|
||||
}()
|
||||
|
||||
drainLinkUpdates(events)
|
||||
|
||||
select {
|
||||
case <-sent:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("netlink sender is still parked on an unread link update")
|
||||
}
|
||||
}
|
||||
|
||||
// stubNetwork is a minimal vip.Network implementation; only ARPName matters here.
|
||||
type stubNetwork struct {
|
||||
name string
|
||||
deleteStarted chan struct{}
|
||||
releaseDelete chan struct{}
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.deleteStarted != nil {
|
||||
close(s.deleteStarted)
|
||||
<-s.releaseDelete
|
||||
}
|
||||
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 "" }
|
||||
func (s *stubNetwork) CIDR() string { return "" }
|
||||
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 s.name }
|
||||
func (s *stubNetwork) GetPossibleSubnets() string { return "" }
|
||||
func (s *stubNetwork) DHCPFamily() string { return "" }
|
||||
func (s *stubNetwork) IPVSMark() uint32 { return 0 }
|
||||
|
||||
// TestManagerInsertConcurrentFirstRegistrationsDoNotLoseClaims guards the
|
||||
// get-then-store race: two never-before-seen instances for the same ARP name
|
||||
// registering concurrently must both be counted, not just the last writer.
|
||||
func TestManagerInsertConcurrentFirstRegistrationsDoNotLoseClaims(t *testing.T) {
|
||||
m := NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
const concurrent = 8
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range concurrent {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m.Insert(NewInstance(&stubNetwork{name: "shared"}, nil))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := m.Count("shared"); got != concurrent {
|
||||
t.Fatalf("Count() = %d, want %d claims registered", got, concurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerInsertDoesNotJoinEntryBeingRemoved(t *testing.T) {
|
||||
m := NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
deleteStarted := make(chan struct{})
|
||||
releaseDelete := make(chan struct{})
|
||||
first := NewInstance(&stubNetwork{name: "shared", deleteStarted: deleteStarted, releaseDelete: releaseDelete}, nil)
|
||||
m.Insert(first)
|
||||
|
||||
removeDone := make(chan struct{})
|
||||
go func() {
|
||||
m.Remove(first)
|
||||
close(removeDone)
|
||||
}()
|
||||
<-deleteStarted
|
||||
|
||||
insertDone := make(chan struct{})
|
||||
go func() {
|
||||
m.Insert(NewInstance(&stubNetwork{name: "shared"}, nil))
|
||||
close(insertDone)
|
||||
}()
|
||||
close(releaseDelete)
|
||||
<-removeDone
|
||||
<-insertDone
|
||||
|
||||
if got := m.Count("shared"); got != 1 {
|
||||
t.Fatalf("Count() = %d, want replacement claim registered", got)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
gobgp "github.com/osrg/gobgp/v4/pkg/server"
|
||||
)
|
||||
|
||||
type BGPManager interface {
|
||||
AddHost(ctx context.Context, addr string, object string) error
|
||||
DelHost(ctx context.Context, addr string, object string) error
|
||||
}
|
||||
|
||||
// Server manages a server object
|
||||
type Server struct {
|
||||
s *gobgp.BgpServer
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
196
pkg/cluster/cluster_internal_test.go
Normal file
196
pkg/cluster/cluster_internal_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -29,14 +29,7 @@ import (
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// BGPRouteManager allows to manage the routes announced by the BGP server.
|
||||
type BGPRouteManager interface {
|
||||
AddHost(ctx context.Context, addr string, object string) error
|
||||
DelHost(ctx context.Context, addr string, object string) error
|
||||
}
|
||||
|
||||
func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config, em *election.Manager,
|
||||
bgpServer BGPRouteManager, killFunc func()) error {
|
||||
func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config, em *election.Manager, bgpServer bgp.BGPManager, killFunc func()) error {
|
||||
|
||||
var err error
|
||||
|
||||
@@ -51,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() {
|
||||
@@ -287,7 +283,28 @@ func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) bgpHealthCheckLoop(ctx context.Context, c *kubevip.Config, bgpServer BGPRouteManager, vipCIDR string) {
|
||||
func (cluster *Cluster) bgpHealthCheck(ctx context.Context, c *kubevip.Config) (bool, error) {
|
||||
statusCode := 0
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ControlPlaneHealthCheck.Address, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building request %v: %w", req, err)
|
||||
} else {
|
||||
resp, err := cluster.healthCheckHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking control-plane: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
statusCode = resp.StatusCode
|
||||
}
|
||||
healthy := statusCode == http.StatusOK
|
||||
if !healthy {
|
||||
return healthy, fmt.Errorf("wrong status code: %d", statusCode)
|
||||
}
|
||||
return healthy, nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) bgpHealthCheckLoop(ctx context.Context, c *kubevip.Config, bgpServer bgp.BGPManager, vipCIDR string) {
|
||||
period := time.Duration(c.ControlPlaneHealthCheck.PeriodSeconds) * time.Second
|
||||
|
||||
consecutiveFailures := 0
|
||||
@@ -304,24 +321,7 @@ func (cluster *Cluster) bgpHealthCheckLoop(ctx context.Context, c *kubevip.Confi
|
||||
)
|
||||
|
||||
for {
|
||||
statusCode := 0
|
||||
var healthErr error
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ControlPlaneHealthCheck.Address, nil)
|
||||
if err != nil {
|
||||
healthErr = err
|
||||
} else {
|
||||
resp, err := cluster.healthCheckHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
healthErr = err
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
statusCode = resp.StatusCode
|
||||
}
|
||||
}
|
||||
|
||||
healthy := healthErr == nil && statusCode == http.StatusOK
|
||||
|
||||
healthy, healthErr := cluster.bgpHealthCheck(ctx, c)
|
||||
if healthy {
|
||||
consecutiveFailures = 0
|
||||
if !routeAnnounced {
|
||||
@@ -336,10 +336,7 @@ func (cluster *Cluster) bgpHealthCheckLoop(ctx context.Context, c *kubevip.Confi
|
||||
consecutiveFailures++
|
||||
if healthErr != nil {
|
||||
log.Warn("BGP health check failed", "address", c.ControlPlaneHealthCheck.Address, "consecutive", consecutiveFailures, "err", healthErr)
|
||||
} else {
|
||||
log.Warn("BGP health check failed", "address", c.ControlPlaneHealthCheck.Address, "consecutive", consecutiveFailures, "status", statusCode)
|
||||
}
|
||||
|
||||
if consecutiveFailures >= c.ControlPlaneHealthCheck.FailureThreshold && routeAnnounced {
|
||||
log.Warn("BGP health check threshold reached, withdrawing route", "failureThreshold", c.ControlPlaneHealthCheck.FailureThreshold, "cidr", vipCIDR)
|
||||
if err := bgpServer.DelHost(ctx, vipCIDR, c.NodeName); err != nil {
|
||||
@@ -399,16 +396,59 @@ func getNodeIPs(ctx context.Context, nodename string, client *kubernetes.Clients
|
||||
}
|
||||
|
||||
// StartLoadBalancerService will start a VIP instance and leave it for kube-proxy to handle
|
||||
func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip.Config, bgp *bgp.Server, name string, wg *sync.WaitGroup) error {
|
||||
func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip.Config, bgp bgp.BGPManager, name string, wg *sync.WaitGroup) error {
|
||||
// use a Go context so we can tell the arp loop code when we
|
||||
// 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{})
|
||||
@@ -431,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)
|
||||
|
||||
@@ -442,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")
|
||||
}
|
||||
}
|
||||
@@ -450,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())
|
||||
}
|
||||
@@ -469,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]
|
||||
|
||||
@@ -488,7 +535,7 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cluster.stop:
|
||||
case <-stop:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
@@ -509,8 +556,9 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
|
||||
return
|
||||
}
|
||||
|
||||
cluster.cleanupVIPs(c)
|
||||
cluster.cleanupServiceVIPs(c, done)
|
||||
})
|
||||
servicesWorkerStarted = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package cluster_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
@@ -28,19 +30,123 @@ func TestBGPHealthCheckLoop_AnnouncesOnHealthy(t *testing.T) {
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
startVipService(t, newTestConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"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)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
startVipService(t, newTestConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectConsistently(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
2*time.Second, "route should not be announced while unhealthy")
|
||||
@@ -56,7 +162,7 @@ func TestBGPHealthCheckLoop_WithdrawsAfterThreshold(t *testing.T) {
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newTestConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 3
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
@@ -78,7 +184,7 @@ func TestBGPHealthCheckLoop_ReAnnouncesOnRecovery(t *testing.T) {
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newTestConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 1
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
@@ -100,7 +206,7 @@ func TestBGPHealthCheckLoop_StopsOnContextCancel(t *testing.T) {
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cancelContext, vipServiceDone := startVipService(t, newTestConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
cancelContext, vipServiceDone := startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
@@ -121,7 +227,7 @@ func TestBGPHealthCheckLoop_RetriesAddHostOnFailure(t *testing.T) {
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
bgpManager.setAddErr(errTestAddHost)
|
||||
startVipService(t, newTestConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectConsistently(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
2*time.Second, "route should not be announced while AddHost errors")
|
||||
@@ -137,7 +243,7 @@ func TestBGPHealthCheckLoop_RetriesDelHostOnFailure(t *testing.T) {
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newTestConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 1
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
@@ -195,9 +301,8 @@ func (e *testError) Error() string { return e.msg }
|
||||
// startVipService launches vipService in a goroutine with a mock network and
|
||||
// registers a cleanup to cancel the context and wait for it to finish.
|
||||
// Uses InitCluster so the real code parses certs for the BGP health check client.
|
||||
func startVipService(t *testing.T, cfg *kubevip.Config, bgpManager *mockBGPRouteManager) (context.CancelFunc, <-chan struct{}) {
|
||||
func startVipService(t *testing.T, cfg *kubevip.Config, bgpServer bgp.BGPManager) (context.CancelFunc, <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
c, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster: %v", err)
|
||||
@@ -208,7 +313,7 @@ func startVipService(t *testing.T, cfg *kubevip.Config, bgpManager *mockBGPRoute
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
_ = c.StartVipService(ctx, cfg, nil, bgpManager, func() {})
|
||||
_ = c.StartVipService(ctx, cfg, nil, bgpServer, func() {})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
@@ -247,14 +352,14 @@ func startRoutingTableVipService(t *testing.T, cfg *kubevip.Config, network *moc
|
||||
}
|
||||
|
||||
func newRoutingTableConfig(url, caPath string) *kubevip.Config {
|
||||
cfg := newTestConfig(url, caPath)
|
||||
cfg := newBGPConfig(url, caPath)
|
||||
cfg.EnableBGP = false
|
||||
cfg.EnableRoutingTable = true
|
||||
cfg.BackendHealthCheckInterval = 1
|
||||
return cfg
|
||||
}
|
||||
|
||||
func newTestConfig(url, caPath string) *kubevip.Config {
|
||||
func newBGPConfig(url, caPath string) *kubevip.Config {
|
||||
return &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
@@ -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 "" }
|
||||
|
||||
@@ -59,7 +59,7 @@ func NewManager(config *kubevip.Config, k8sClientset, rwClientset *kubernetes.Cl
|
||||
func RunOrDie(ctx context.Context, run *RunConfig, c *kubevip.Config) error {
|
||||
switch c.LeaderElectionType {
|
||||
case "kubernetes", "":
|
||||
runKubernetesLeaderElectionOrDie(ctx, run)
|
||||
return runKubernetesLeaderElectionOrDie(ctx, run)
|
||||
case "etcd":
|
||||
if err := runEtcdLeaderElectionOrDie(ctx, run); err != nil {
|
||||
return err
|
||||
@@ -71,20 +71,25 @@ func RunOrDie(ctx context.Context, run *RunConfig, c *kubevip.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runKubernetesLeaderElectionOrDie(ctx context.Context, run *RunConfig) {
|
||||
func runKubernetesLeaderElectionOrDie(ctx context.Context, run *RunConfig) error {
|
||||
annotations, err := kubevip.WithLeaseVIPs(run.LeaseAnnotations, run.Config.InstanceName, run.Config.RoutingProtocol, run.VIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseClient := run.Mgr.KubernetesClient.CoordinationV1().Leases(run.LeaseID.Namespace())
|
||||
// we use the Lease lock type since edits to Leases are less common
|
||||
// and fewer objects in the cluster watch "all Leases".
|
||||
lock := &resourcelock.LeaseLock{
|
||||
baseLock := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{
|
||||
Name: run.LeaseID.Name(),
|
||||
Namespace: run.LeaseID.Namespace(),
|
||||
Annotations: run.LeaseAnnotations,
|
||||
Name: run.LeaseID.Name(),
|
||||
Namespace: run.LeaseID.Namespace(),
|
||||
},
|
||||
Client: run.Mgr.KubernetesClient.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{
|
||||
Identity: run.Config.NodeName,
|
||||
},
|
||||
}
|
||||
lock := newAnnotatedLeaseLock(baseLock, leaseClient, run.LeaseID.Name(), annotations)
|
||||
|
||||
// start the leader election code loop
|
||||
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
|
||||
@@ -105,6 +110,7 @@ func runKubernetesLeaderElectionOrDie(ctx context.Context, run *RunConfig) {
|
||||
OnNewLeader: run.OnNewLeader,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEtcdLeaderElectionOrDie(ctx context.Context, run *RunConfig) error {
|
||||
@@ -135,6 +141,7 @@ type RunConfig struct {
|
||||
LeaseID lease.ID
|
||||
Mgr *Manager
|
||||
LeaseAnnotations map[string]string
|
||||
VIPs []string
|
||||
|
||||
// onStartedLeading is called when this member starts leading.
|
||||
OnStartedLeading func(context.Context)
|
||||
|
||||
90
pkg/election/lease_lock.go
Normal file
90
pkg/election/lease_lock.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
coordinationv1client "k8s.io/client-go/kubernetes/typed/coordination/v1"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
"k8s.io/client-go/util/retry"
|
||||
)
|
||||
|
||||
type annotatedLeaseLock struct {
|
||||
resourcelock.Interface
|
||||
leases coordinationv1client.LeaseInterface
|
||||
name string
|
||||
annotations map[string]string
|
||||
}
|
||||
|
||||
func newAnnotatedLeaseLock(lock resourcelock.Interface, leases coordinationv1client.LeaseInterface,
|
||||
name string, annotations map[string]string) resourcelock.Interface {
|
||||
return &annotatedLeaseLock{Interface: lock, leases: leases, name: name, annotations: annotations}
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Get(ctx context.Context) (*resourcelock.LeaderElectionRecord, []byte, error) {
|
||||
return lock.Interface.Get(ctx)
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Create(ctx context.Context, record resourcelock.LeaderElectionRecord) error {
|
||||
if err := lock.Interface.Create(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.HolderIdentity != lock.Identity() {
|
||||
return nil
|
||||
}
|
||||
changed, err := lock.ensureAnnotations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
_, _, err = lock.Interface.Get(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Update(ctx context.Context, record resourcelock.LeaderElectionRecord) error {
|
||||
if err := lock.Interface.Update(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.HolderIdentity != lock.Identity() {
|
||||
return nil
|
||||
}
|
||||
changed, err := lock.ensureAnnotations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
_, _, err = lock.Interface.Get(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) ensureAnnotations(ctx context.Context) (bool, error) {
|
||||
changed := false
|
||||
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
resource, err := lock.leases.Get(ctx, lock.name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resource.Annotations == nil {
|
||||
resource.Annotations = make(map[string]string, len(lock.annotations))
|
||||
}
|
||||
resourceChanged := false
|
||||
for key, value := range lock.annotations {
|
||||
if resource.Annotations[key] == value {
|
||||
continue
|
||||
}
|
||||
resource.Annotations[key] = value
|
||||
resourceChanged = true
|
||||
}
|
||||
if !resourceChanged {
|
||||
return nil
|
||||
}
|
||||
_, err = lock.leases.Update(ctx, resource, metav1.UpdateOptions{})
|
||||
if err == nil {
|
||||
changed = true
|
||||
}
|
||||
return err
|
||||
})
|
||||
return changed, err
|
||||
}
|
||||
139
pkg/election/lease_lock_test.go
Normal file
139
pkg/election/lease_lock_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
)
|
||||
|
||||
func TestAnnotatedLeaseLockPersistsAnnotationsOnCreateAndUpdate(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
base := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{
|
||||
Identity: "node-a",
|
||||
},
|
||||
}
|
||||
annotations, err := kubevip.WithLeaseVIPs(map[string]string{"example.test/preserved": "true"},
|
||||
"release_a", 248, []string{"192.0.2.10"})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
lock := newAnnotatedLeaseLock(base, leaseClient, "lease", annotations)
|
||||
record := resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}
|
||||
if err := lock.Create(context.Background(), record); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if err := lock.Update(context.Background(), record); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("get Lease: %v", err)
|
||||
}
|
||||
value, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if value.InstanceName != "release_a" || value.IFAProto != 248 || len(value.VIPs) != 1 ||
|
||||
value.VIPs[0] != (kubevip.LeaseVIP{Index: 0, Value: "192.0.2.10"}) {
|
||||
t.Fatalf("Lease VIP metadata = %+v", value)
|
||||
}
|
||||
if resource.Annotations["example.test/preserved"] != "true" {
|
||||
t.Fatal("Lease update dropped a configured annotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatedLeaseLockFollowerDoesNotOverwriteAnnotations(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
newBase := func(identity string) *resourcelock.LeaseLock {
|
||||
return &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{Identity: identity},
|
||||
}
|
||||
}
|
||||
ownerBase := newBase("node-a")
|
||||
followerBase := newBase("node-b")
|
||||
active, err := kubevip.WithLeaseVIPs(nil, "release_a", 248, []string{"192.0.2.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creator := newAnnotatedLeaseLock(ownerBase, leaseClient, "lease", active)
|
||||
if err := creator.Create(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
follower, err := kubevip.WithLeaseVIPs(nil, "release_b", 249, []string{"192.0.2.20"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer := newAnnotatedLeaseLock(followerBase, leaseClient, "lease", follower)
|
||||
if _, _, err := observer.Get(context.Background()); err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.InstanceName != "release_a" || metadata.IFAProto != 248 {
|
||||
t.Fatalf("follower overwrote active metadata: %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatedLeaseLockReleaseDoesNotOverwriteSuccessorMetadata(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
newLock := func(identity, instanceName string, protocol int, vip string) resourcelock.Interface {
|
||||
base := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{Identity: identity},
|
||||
}
|
||||
annotations, err := kubevip.WithLeaseVIPs(nil, instanceName, protocol, []string{vip})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newAnnotatedLeaseLock(base, leaseClient, "lease", annotations)
|
||||
}
|
||||
|
||||
first := newLock("node-a", "release_a", 248, "192.0.2.10")
|
||||
if err := first.Create(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Update(context.Background(), resourcelock.LeaderElectionRecord{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := newLock("node-b", "release_b", 249, "192.0.2.20")
|
||||
if _, _, err := second.Get(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.Update(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.InstanceName != "release_b" || metadata.IFAProto != 249 || metadata.VIPs[0].Value != "192.0.2.20" {
|
||||
t.Fatalf("successor metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
152
pkg/endpoints/cleanup.go
Normal file
152
pkg/endpoints/cleanup.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -102,7 +102,7 @@ func TestUpdateAnnotationsZeroEndpointsThenSameEndpoint(t *testing.T) {
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-service", Namespace: "default", UID: "test-uid", Annotations: annotations,
|
||||
}}
|
||||
serviceInstance := &instance.Instance{ServiceSnapshot: service.DeepCopy()}
|
||||
serviceInstance := &instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}
|
||||
instances := []*instance.Instance{serviceInstance}
|
||||
recorder := &recordingProvider{Provider: provider}
|
||||
processor := &Processor{
|
||||
@@ -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}
|
||||
@@ -167,7 +168,7 @@ func TestUpdateAnnotationsEndpointSlicesClearsConfiguredFamily(t *testing.T) {
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-service", Namespace: "default", UID: "test-uid", Annotations: annotations,
|
||||
}}
|
||||
instances := []*instance.Instance{{ServiceSnapshot: service.DeepCopy()}}
|
||||
instances := []*instance.Instance{{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}}
|
||||
recorder := &recordingProvider{Provider: providers.NewEndpointslices()}
|
||||
processor := &Processor{
|
||||
config: &kubevip.Config{EnableEndpoints: false},
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
@@ -46,18 +49,21 @@ type Instance struct {
|
||||
DHCPv6Client vip.DHCPClient
|
||||
macvlanName string
|
||||
dhcpBroadcast bool
|
||||
dhcpInterfaceOwned atomic.Bool
|
||||
|
||||
// Service use Vlan
|
||||
IsVLAN bool
|
||||
VLANInterface string
|
||||
vlanOwned atomic.Bool
|
||||
|
||||
// External Gateway IP the service is forwarded from
|
||||
UPNPGatewayIPs []string
|
||||
|
||||
// Kubernetes service mapping
|
||||
ServiceSnapshot *v1.Service
|
||||
|
||||
dnsAddresses []string
|
||||
ServiceUID types.UID
|
||||
ServiceAddresses []string
|
||||
ServiceSnapshot *v1.Service
|
||||
cleanupInfo *ServiceCleanupInfo
|
||||
|
||||
// AddCalled determined that ActionAdd was already performed for the instance
|
||||
AddCalled bool
|
||||
@@ -67,6 +73,48 @@ type Instance struct {
|
||||
LabelAdded bool
|
||||
}
|
||||
|
||||
func (instance *Instance) UID() types.UID {
|
||||
return instance.ServiceUID
|
||||
}
|
||||
|
||||
func (instance *Instance) Addresses() []string {
|
||||
if instance.ServiceAddresses != nil {
|
||||
return append([]string(nil), instance.ServiceAddresses...)
|
||||
}
|
||||
addresses, _ := FetchServiceAddresses(instance.ServiceSnapshot)
|
||||
return addresses
|
||||
}
|
||||
|
||||
type ServiceCleanupInfo struct {
|
||||
Namespace string
|
||||
Name string
|
||||
Lease string
|
||||
ExternalTrafficPolicy v1.ServiceExternalTrafficPolicy
|
||||
}
|
||||
|
||||
// CleanupInfo returns Service policy captured when the instance was created.
|
||||
func (instance *Instance) CleanupInfo() (ServiceCleanupInfo, bool) {
|
||||
if instance == nil {
|
||||
return ServiceCleanupInfo{}, false
|
||||
}
|
||||
if instance.cleanupInfo != nil {
|
||||
return *instance.cleanupInfo, true
|
||||
}
|
||||
if instance.ServiceSnapshot == nil {
|
||||
return ServiceCleanupInfo{}, false
|
||||
}
|
||||
return serviceCleanupInfo(instance.ServiceSnapshot), true
|
||||
}
|
||||
|
||||
func serviceCleanupInfo(service *v1.Service) ServiceCleanupInfo {
|
||||
return ServiceCleanupInfo{
|
||||
Namespace: service.Namespace,
|
||||
Name: service.Name,
|
||||
Lease: service.Annotations[kubevip.ServiceLease],
|
||||
ExternalTrafficPolicy: service.Spec.ExternalTrafficPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
type Port struct {
|
||||
Port uint16
|
||||
Type string
|
||||
@@ -78,16 +126,25 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
instanceAddresses, instanceHostnames := FetchServiceAddresses(svc)
|
||||
log.Info("new instance", "namespace", svc.Namespace, "service", svc.Name, "addresses", instanceAddresses, "hostnames", instanceHostnames)
|
||||
|
||||
cleanupInfo := serviceCleanupInfo(svc)
|
||||
instance := &Instance{
|
||||
ServiceUID: svc.UID,
|
||||
ServiceAddresses: append([]string(nil), instanceAddresses...),
|
||||
ServiceSnapshot: svc,
|
||||
cleanupInfo: &cleanupInfo,
|
||||
}
|
||||
if err := instance.initialize(ctx, svc, config, intfMgr, arpMgr, routeMgr, nodeLabelMgr, wg, instanceAddresses, instanceHostnames); err != nil {
|
||||
return nil, errors.Join(err, instance.CleanupLinkAttachments())
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (instance *Instance) initialize(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
intfMgr *networkinterface.Manager, arpMgr *arp.Manager, routeMgr *route.Manager,
|
||||
nodeLabelMgr node.Labeler, wg *sync.WaitGroup, instanceAddresses, instanceHostnames []string) error {
|
||||
var newVips []*kubevip.Config
|
||||
var link netlink.Link
|
||||
var err error
|
||||
var dnsAddresses []string
|
||||
|
||||
// Create new service
|
||||
instance := &Instance{
|
||||
ServiceSnapshot: svc,
|
||||
dnsAddresses: dnsAddresses,
|
||||
}
|
||||
|
||||
for _, address := range instanceAddresses {
|
||||
// Detect if we're using a specific interface for services
|
||||
@@ -143,10 +200,10 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
|
||||
if link == nil {
|
||||
if link, err = netlink.LinkByName(svcInterface); err != nil {
|
||||
return nil, fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
return fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
}
|
||||
if link == nil {
|
||||
return nil, fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
return fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +220,7 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
}
|
||||
|
||||
if (config.Address != "" || config.VIP != "") && (ipv4AutoSubnet || ipv6AutoSubnet) {
|
||||
return nil, fmt.Errorf("auto subnet discovery cannot be used if VIP address was provided")
|
||||
return fmt.Errorf("auto subnet discovery cannot be used if VIP address was provided")
|
||||
}
|
||||
|
||||
subnet := ""
|
||||
@@ -172,7 +229,7 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
if ipv4AutoSubnet {
|
||||
subnet, err = autoFindSubnet(link, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
return fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
}
|
||||
} else {
|
||||
if cidrs[0] != "" && cidrs[0] != kubevip.Auto {
|
||||
@@ -185,7 +242,7 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
if ipv6AutoSubnet {
|
||||
subnet, err = autoFindSubnet(link, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
return fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
}
|
||||
} else {
|
||||
if len(cidrs) > 1 && cidrs[1] != "" && cidrs[1] != kubevip.Auto {
|
||||
@@ -198,25 +255,26 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
|
||||
// Generate new Virtual IP configuration
|
||||
newVips = append(newVips, &kubevip.Config{
|
||||
VIP: address,
|
||||
Interface: svcInterface,
|
||||
SingleNode: true,
|
||||
EnableARP: config.EnableARP,
|
||||
EnableBGP: config.EnableBGP,
|
||||
BGPAttachIPToInterface: config.BGPAttachIPToInterface,
|
||||
VIPSubnet: subnet,
|
||||
EnableRoutingTable: config.EnableRoutingTable,
|
||||
RoutingTableID: config.RoutingTableID,
|
||||
RoutingTableType: config.RoutingTableType,
|
||||
RoutingProtocol: config.RoutingProtocol,
|
||||
SkipDAD: config.SkipDAD,
|
||||
ArpBroadcastRate: config.ArpBroadcastRate,
|
||||
EnableServiceSecurity: config.EnableServiceSecurity,
|
||||
DNSMode: config.DNSMode,
|
||||
DHCPMode: config.DHCPMode,
|
||||
DHCPBackoffAttempts: config.DHCPBackoffAttempts,
|
||||
DisableServiceUpdates: config.DisableServiceUpdates,
|
||||
EnableServicesElection: config.EnableServicesElection,
|
||||
VIP: address,
|
||||
Interface: svcInterface,
|
||||
SingleNode: true,
|
||||
EnableARP: config.EnableARP,
|
||||
EnableBGP: config.EnableBGP,
|
||||
BGPAttachIPToInterface: config.BGPAttachIPToInterface,
|
||||
VIPSubnet: subnet,
|
||||
EnableRoutingTable: config.EnableRoutingTable,
|
||||
RoutingTableID: config.RoutingTableID,
|
||||
RoutingTableType: config.RoutingTableType,
|
||||
RoutingProtocol: config.RoutingProtocol,
|
||||
SkipDAD: config.SkipDAD,
|
||||
ArpBroadcastRate: config.ArpBroadcastRate,
|
||||
EnableServiceSecurity: config.EnableServiceSecurity,
|
||||
DNSMode: config.DNSMode,
|
||||
DHCPMode: config.DHCPMode,
|
||||
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,
|
||||
@@ -256,10 +314,10 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
|
||||
if link == nil {
|
||||
if link, err = netlink.LinkByName(svcInterface); err != nil {
|
||||
return nil, fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
return fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
}
|
||||
if link == nil {
|
||||
return nil, fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
return fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +353,7 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
if requestedIP != "" {
|
||||
requestedIPs := strings.Split(requestedIP, ",")
|
||||
if len(requestedIPs) > 2 {
|
||||
return nil, fmt.Errorf("annotation %q cannot request more than one IPv4 and one Ipv6 address", kubevip.RequestedIP)
|
||||
return fmt.Errorf("annotation %q cannot request more than one IPv4 and one Ipv6 address", kubevip.RequestedIP)
|
||||
}
|
||||
for _, ip := range requestedIPs {
|
||||
netip := net.ParseIP(ip)
|
||||
@@ -334,36 +392,40 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
// If this was purposely created with the address '0.0.0.0', or '::'
|
||||
// we will create a macvlan on the main interface and a DHCP client
|
||||
if len(instanceAddresses) > 2 && (slices.Contains(instanceAddresses, "0.0.0.0") || slices.Contains(instanceAddresses, "::")) {
|
||||
return nil, fmt.Errorf("DHCP cannot be used if more than 2 addresses (one IPv4 and one IPv6) were specified")
|
||||
return fmt.Errorf("DHCP cannot be used if more than 2 addresses (one IPv4 and one IPv6) were specified")
|
||||
}
|
||||
for i := range instance.VIPConfigs {
|
||||
if instance.VIPConfigs[i].VIP == "0.0.0.0" {
|
||||
err := instance.startDHCP(ctx, i, config.DHCPBackoffAttempts, wg)
|
||||
for index := range instance.VIPConfigs {
|
||||
if instance.VIPConfigs[index].VIP == "0.0.0.0" {
|
||||
err := instance.startDHCP(ctx, index, config.DHCPBackoffAttempts, wg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-instance.DHCPv4Client.ErrorChannel():
|
||||
return nil, fmt.Errorf("error starting DHCPv4 for %s/%s: error: %s",
|
||||
return fmt.Errorf("error starting DHCPv4 for %s/%s: error: %s",
|
||||
instance.ServiceSnapshot.Namespace, instance.ServiceSnapshot.Name, err)
|
||||
case ip := <-instance.DHCPv4Client.IPChannel():
|
||||
instance.VIPConfigs[i].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[i].VIP = ip
|
||||
instance.VIPConfigs[index].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[index].VIP = ip
|
||||
instance.DHCPInterfaceIPv4 = ip
|
||||
}
|
||||
}
|
||||
if instance.VIPConfigs[i].VIP == "::" {
|
||||
err := instance.startDHCP(ctx, i, config.DHCPBackoffAttempts, wg)
|
||||
if instance.VIPConfigs[index].VIP == "::" {
|
||||
err := instance.startDHCP(ctx, index, config.DHCPBackoffAttempts, wg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-instance.DHCPv6Client.ErrorChannel():
|
||||
return nil, fmt.Errorf("error starting DHCPv6 for %s/%s: error: %s",
|
||||
return fmt.Errorf("error starting DHCPv6 for %s/%s: error: %s",
|
||||
instance.ServiceSnapshot.Namespace, instance.ServiceSnapshot.Name, err)
|
||||
case ip := <-instance.DHCPv6Client.IPChannel():
|
||||
instance.VIPConfigs[i].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[i].VIP = ip
|
||||
instance.VIPConfigs[index].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[index].VIP = ip
|
||||
instance.DHCPInterfaceIPv6 = ip
|
||||
}
|
||||
}
|
||||
@@ -371,56 +433,56 @@ func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
ddnsAnnotation, exists := svc.Annotations[kubevip.ServiceDDNS]
|
||||
|
||||
if exists {
|
||||
instance.VIPConfigs[i].DDNS, err = strconv.ParseBool(ddnsAnnotation)
|
||||
instance.VIPConfigs[index].DDNS, err = strconv.ParseBool(ddnsAnnotation)
|
||||
if err != nil {
|
||||
log.Error("Failed to add service", "err", err)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(svc.Spec.IPFamilies) > 0 {
|
||||
if len(svc.Spec.IPFamilies) > 1 {
|
||||
instance.VIPConfigs[i].DHCPMode = utils.DualFamily
|
||||
instance.VIPConfigs[i].DNSMode = utils.DualFamily
|
||||
instance.VIPConfigs[index].DHCPMode = utils.DualFamily
|
||||
instance.VIPConfigs[index].DNSMode = utils.DualFamily
|
||||
switch *svc.Spec.IPFamilyPolicy {
|
||||
case v1.IPFamilyPolicyRequireDualStack:
|
||||
instance.VIPConfigs[i].IsDualStack = true
|
||||
instance.VIPConfigs[i].RequireDualStack = true
|
||||
instance.VIPConfigs[index].IsDualStack = true
|
||||
instance.VIPConfigs[index].RequireDualStack = true
|
||||
case v1.IPFamilyPolicyPreferDualStack:
|
||||
instance.VIPConfigs[i].IsDualStack = true
|
||||
instance.VIPConfigs[i].RequireDualStack = false
|
||||
instance.VIPConfigs[index].IsDualStack = true
|
||||
instance.VIPConfigs[index].RequireDualStack = false
|
||||
default:
|
||||
instance.VIPConfigs[i].IsDualStack = false
|
||||
instance.VIPConfigs[i].RequireDualStack = false
|
||||
instance.VIPConfigs[index].IsDualStack = false
|
||||
instance.VIPConfigs[index].RequireDualStack = false
|
||||
}
|
||||
} else {
|
||||
if strings.EqualFold(string(svc.Spec.IPFamilies[0]), utils.IPv4Family) {
|
||||
instance.VIPConfigs[i].DHCPMode = strings.ToLower(utils.IPv4Family)
|
||||
instance.VIPConfigs[i].DNSMode = strings.ToLower(utils.IPv4Family)
|
||||
instance.VIPConfigs[index].DHCPMode = strings.ToLower(utils.IPv4Family)
|
||||
instance.VIPConfigs[index].DNSMode = strings.ToLower(utils.IPv4Family)
|
||||
} else {
|
||||
instance.VIPConfigs[i].DHCPMode = strings.ToLower(utils.IPv6Family)
|
||||
instance.VIPConfigs[i].DNSMode = strings.ToLower(utils.IPv6Family)
|
||||
instance.VIPConfigs[index].DHCPMode = strings.ToLower(utils.IPv6Family)
|
||||
instance.VIPConfigs[index].DNSMode = strings.ToLower(utils.IPv6Family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
instance.VIPConfigs[i].EgressWithNftables = config.EgressWithNftables
|
||||
instance.VIPConfigs[index].EgressWithNftables = config.EgressWithNftables
|
||||
|
||||
c, err := cluster.InitCluster(instance.VIPConfigs[i], false, intfMgr, arpMgr, routeMgr, nodeLabelMgr)
|
||||
c, err := cluster.InitCluster(instance.VIPConfigs[index], false, intfMgr, arpMgr, routeMgr, nodeLabelMgr)
|
||||
if err != nil {
|
||||
log.Error("failed to add service", "err", err)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range c.Network {
|
||||
c.Network[i].SetServicePorts(svc)
|
||||
for networkIndex := range c.Network {
|
||||
c.Network[networkIndex].SetServicePorts(svc)
|
||||
}
|
||||
|
||||
instance.Clusters = append(instance.Clusters, c)
|
||||
log.Info("(svcs) adding VIP", "ip", instance.VIPConfigs[i].VIP, "interface", instance.VIPConfigs[i].Interface, "namespace", svc.Namespace, "name", svc.Name)
|
||||
log.Info("(svcs) adding VIP", "ip", instance.VIPConfigs[index].VIP, "interface", instance.VIPConfigs[index].Interface, "namespace", svc.Namespace, "name", svc.Name)
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoFindInterface(ip string) (netlink.Link, error) {
|
||||
@@ -480,16 +542,17 @@ func getAutoInterfaceName(link netlink.Link, defaultInterface string) string {
|
||||
return link.Attrs().Name
|
||||
}
|
||||
|
||||
func (i *Instance) addVLAN(parentInterface string, tag int) error {
|
||||
var parent netlink.Link
|
||||
|
||||
func (instance *Instance) addVLAN(parentInterface string, tag int) error {
|
||||
interfaceName := fmt.Sprintf("%s.%d", parentInterface, tag)
|
||||
parent, err := netlink.LinkByName(parentInterface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding VLAN parent interface %s: %w", parentInterface, err)
|
||||
}
|
||||
iface, err := netlink.LinkByName(interfaceName)
|
||||
if err != nil {
|
||||
// check if parent interface doesnt exist
|
||||
parent, err = netlink.LinkByName(parentInterface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding VLAN parent interface %s: %v", parentInterface, err)
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
return fmt.Errorf("finding VLAN interface %s: %w", interfaceName, err)
|
||||
}
|
||||
|
||||
log.Info("Creating new VLAN interface", "interface", interfaceName)
|
||||
@@ -507,6 +570,7 @@ func (i *Instance) addVLAN(parentInterface string, tag int) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add VLAN %s: %v", interfaceName, err)
|
||||
}
|
||||
instance.vlanOwned.Store(true)
|
||||
|
||||
err = netlink.LinkSetUp(vlan)
|
||||
if err != nil {
|
||||
@@ -525,26 +589,96 @@ func (i *Instance) addVLAN(parentInterface string, tag int) error {
|
||||
}
|
||||
}
|
||||
|
||||
i.VLANInterface = interfaceName
|
||||
i.IsVLAN = true
|
||||
instance.VLANInterface = interfaceName
|
||||
instance.IsVLAN = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uint, wg *sync.WaitGroup) error {
|
||||
if len(i.VIPConfigs) > 2 {
|
||||
return fmt.Errorf("DHCP can be used with 2 VIP config maximally, got: %v", len(i.VIPConfigs))
|
||||
// CleanupLinkAttachments stops this instance's DHCP clients and removes only
|
||||
// VLAN or macvlan links created by this instance that are not used by a
|
||||
// remaining Service instance.
|
||||
func (instance *Instance) CleanupLinkAttachments(remaining ...*Instance) error {
|
||||
var errs []error
|
||||
if instance.DHCPv4Client != nil {
|
||||
instance.DHCPv4Client.Stop()
|
||||
}
|
||||
parent, err := netlink.LinkByName(i.VIPConfigs[index].Interface)
|
||||
if instance.DHCPv6Client != nil {
|
||||
instance.DHCPv6Client.Stop()
|
||||
}
|
||||
if instance.dhcpInterfaceOwned.Load() {
|
||||
if transferLinkAttachmentOwnership(instance.DHCPInterface, remaining, false) {
|
||||
instance.dhcpInterfaceOwned.Store(false)
|
||||
} else if err := deleteOwnedLink(instance.DHCPInterface, "DHCP"); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
instance.dhcpInterfaceOwned.Store(false)
|
||||
}
|
||||
}
|
||||
if instance.vlanOwned.Load() {
|
||||
if transferLinkAttachmentOwnership(instance.VLANInterface, remaining, true) {
|
||||
instance.vlanOwned.Store(false)
|
||||
} else if err := deleteOwnedLink(instance.VLANInterface, "VLAN"); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
instance.vlanOwned.Store(false)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func transferLinkAttachmentOwnership(name string, instances []*Instance, vlan bool) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance == nil {
|
||||
continue
|
||||
}
|
||||
if vlan && instance.IsVLAN && instance.VLANInterface == name {
|
||||
instance.vlanOwned.Store(true)
|
||||
return true
|
||||
}
|
||||
if !vlan && (instance.IsDHCPv4 || instance.IsDHCPv6) && instance.DHCPInterface == name {
|
||||
instance.dhcpInterfaceOwned.Store(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deleteOwnedLink(name, kind string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err != nil {
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("find %s interface %q: %w", kind, name, err)
|
||||
}
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
return fmt.Errorf("delete %s interface %q: %w", kind, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (instance *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uint, wg *sync.WaitGroup) error {
|
||||
if len(instance.VIPConfigs) > 2 {
|
||||
return fmt.Errorf("DHCP can be used with 2 VIP config maximally, got: %v", len(instance.VIPConfigs))
|
||||
}
|
||||
parent, err := netlink.LinkByName(instance.VIPConfigs[index].Interface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding VIP Interface, for building DHCP Link : %v", err)
|
||||
}
|
||||
|
||||
interfaceName := i.macvlanName
|
||||
interfaceName := instance.macvlanName
|
||||
|
||||
if interfaceName == "" {
|
||||
// Generate name from UID
|
||||
interfaceName = fmt.Sprintf("vip-%s", i.ServiceSnapshot.UID[0:8])
|
||||
interfaceName = fmt.Sprintf("vip-%s", instance.UID()[0:8])
|
||||
}
|
||||
|
||||
// Check if the interface doesn't exist first
|
||||
@@ -552,8 +686,8 @@ func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uin
|
||||
if err != nil {
|
||||
log.Info("creating new macvlan interface for DHCP", "interface", interfaceName)
|
||||
|
||||
hwaddr, err := net.ParseMAC(i.DHCPInterfaceHwaddr)
|
||||
if i.DHCPInterfaceHwaddr != "" && err != nil {
|
||||
hwaddr, err := net.ParseMAC(instance.DHCPInterfaceHwaddr)
|
||||
if instance.DHCPInterfaceHwaddr != "" && err != nil {
|
||||
return err
|
||||
} else if hwaddr == nil {
|
||||
hwaddr, err = net.ParseMAC(vip.GenerateMac())
|
||||
@@ -576,6 +710,7 @@ func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uin
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add %s: %v", interfaceName, err)
|
||||
}
|
||||
instance.dhcpInterfaceOwned.Store(true)
|
||||
|
||||
err = netlink.LinkSetUp(mac)
|
||||
if err != nil {
|
||||
@@ -591,7 +726,7 @@ func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uin
|
||||
}
|
||||
|
||||
var initRebootFlag bool
|
||||
ip := net.ParseIP(i.VIPConfigs[index].VIP)
|
||||
ip := net.ParseIP(instance.VIPConfigs[index].VIP)
|
||||
|
||||
var client vip.DHCPClient
|
||||
if ip.To4() != nil {
|
||||
@@ -599,14 +734,14 @@ func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uin
|
||||
rpfilterSetting := "0"
|
||||
|
||||
// Check if we need to set an override rp_filter value for the interface
|
||||
if i.ServiceSnapshot.Annotations[kubevip.RPFilter] != "" {
|
||||
if instance.ServiceSnapshot.Annotations[kubevip.RPFilter] != "" {
|
||||
// Check the rp_filter value
|
||||
rpFilter, err := strconv.Atoi(i.ServiceSnapshot.Annotations[kubevip.RPFilter])
|
||||
rpFilter, err := strconv.Atoi(instance.ServiceSnapshot.Annotations[kubevip.RPFilter])
|
||||
if err != nil {
|
||||
log.Error("[DHCP] unable to process rp_filter", "value", rpFilter)
|
||||
} else {
|
||||
if rpFilter >= 0 && rpFilter < 3 { // Ensure the value is 0,1,2
|
||||
rpfilterSetting = i.ServiceSnapshot.Annotations[kubevip.RPFilter]
|
||||
rpfilterSetting = instance.ServiceSnapshot.Annotations[kubevip.RPFilter]
|
||||
} else {
|
||||
log.Error("[DHCP] rp_filter value not within range 0-2", "value", rpFilter)
|
||||
}
|
||||
@@ -618,49 +753,50 @@ func (i *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uin
|
||||
log.Error("[DHCP] unable to write rp_filter", "value", rpfilterSetting, "err", err)
|
||||
}
|
||||
|
||||
if i.DHCPInterfaceIPv4 != "" {
|
||||
if instance.DHCPInterfaceIPv4 != "" {
|
||||
initRebootFlag = true
|
||||
}
|
||||
|
||||
client = vip.NewDHCPv4Client(iface, initRebootFlag, i.DHCPInterfaceIPv4, backoffAttempts, i.dhcpBroadcast)
|
||||
client = vip.NewDHCPv4Client(iface, initRebootFlag, instance.DHCPInterfaceIPv4, backoffAttempts, instance.dhcpBroadcast)
|
||||
|
||||
// Add the client so that we can call it to stop function
|
||||
i.DHCPv4Client = client
|
||||
instance.DHCPv4Client = client
|
||||
|
||||
// Set that DHCPv4 is enabled
|
||||
i.IsDHCPv4 = true
|
||||
instance.IsDHCPv4 = true
|
||||
} else {
|
||||
if i.DHCPInterfaceIPv6 != "" {
|
||||
if instance.DHCPInterfaceIPv6 != "" {
|
||||
initRebootFlag = true
|
||||
}
|
||||
|
||||
client, err = vip.NewDHCPv6Client(iface, parent, initRebootFlag, i.DHCPInterfaceIPv6, backoffAttempts)
|
||||
client, err = vip.NewDHCPv6Client(iface, parent, initRebootFlag, instance.DHCPInterfaceIPv6, backoffAttempts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create client: %w", err)
|
||||
}
|
||||
|
||||
// Add the client so that we can call it to stop function
|
||||
i.DHCPv6Client = client
|
||||
instance.DHCPv6Client = client
|
||||
|
||||
// Set that DHCPv6 is enabled
|
||||
i.IsDHCPv6 = true
|
||||
instance.IsDHCPv6 = true
|
||||
}
|
||||
|
||||
// Add hostname to dhcp client if annotated
|
||||
if i.DHCPHostname != "" {
|
||||
log.Info("Hostname specified for dhcp lease", "interface", interfaceName, "hostname", i.DHCPHostname)
|
||||
client.WithHostName(i.DHCPHostname)
|
||||
if instance.DHCPHostname != "" {
|
||||
log.Info("Hostname specified for dhcp lease", "interface", interfaceName, "hostname", instance.DHCPHostname)
|
||||
client.WithHostName(instance.DHCPHostname)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
if err := client.Start(ctx); err != nil {
|
||||
log.Error("[instance] DHCP client error: %w")
|
||||
log.Error("[instance] DHCP client", "error", err)
|
||||
client.Stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the name of the interface so that it can be removed on Service deletion
|
||||
i.DHCPInterface = interfaceName
|
||||
i.DHCPInterfaceHwaddr = iface.HardwareAddr.String()
|
||||
instance.DHCPInterface = interfaceName
|
||||
instance.DHCPInterfaceHwaddr = iface.HardwareAddr.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -740,9 +876,9 @@ func FetchServiceAddresses(s *v1.Service) ([]string, []string) {
|
||||
|
||||
func FindServiceInstance(svc *v1.Service, instances []*Instance) *Instance {
|
||||
log.Debug("finding service", "namespace", svc.Namespace, "name", svc.Name, "UID", svc.UID)
|
||||
for i := range instances {
|
||||
if instances[i].ServiceSnapshot.UID == svc.UID {
|
||||
return instances[i]
|
||||
for index := range instances {
|
||||
if instances[index].UID() == svc.UID {
|
||||
return instances[index]
|
||||
}
|
||||
}
|
||||
log.Debug("instance not found", "namespace", svc.Namespace, "name", svc.Name, "UID", svc.UID)
|
||||
|
||||
65
pkg/instance/instance_test.go
Normal file
65
pkg/instance/instance_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
func TestUIDUsesImmutableServiceUID(t *testing.T) {
|
||||
serviceUID := types.UID("original-service")
|
||||
instance := &Instance{
|
||||
ServiceUID: serviceUID,
|
||||
ServiceSnapshot: &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID("replacement-service"),
|
||||
}},
|
||||
}
|
||||
|
||||
if got := instance.UID(); got != serviceUID {
|
||||
t.Fatalf("UID() = %q, want %q", got, serviceUID)
|
||||
}
|
||||
|
||||
instance.ServiceUID = ""
|
||||
if got := instance.UID(); got != "" {
|
||||
t.Fatalf("UID() without ServiceUID = %q, want empty UID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStateIsImmutable(t *testing.T) {
|
||||
original := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "service", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
|
||||
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
|
||||
immutableInfo := serviceCleanupInfo(original)
|
||||
instance := &Instance{
|
||||
ServiceSnapshot: original,
|
||||
ServiceAddresses: []string{"192.0.2.10"},
|
||||
cleanupInfo: &immutableInfo,
|
||||
}
|
||||
instance.ServiceSnapshot = &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "changed"}}
|
||||
|
||||
cleanupInfo, ok := instance.CleanupInfo()
|
||||
if !ok || cleanupInfo.Namespace != "default" || cleanupInfo.Name != "service" || cleanupInfo.Lease != "shared" ||
|
||||
cleanupInfo.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
t.Fatalf("CleanupInfo() = %+v, %t, want creation-time Service policy", cleanupInfo, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferLinkAttachmentOwnershipConcurrent(t *testing.T) {
|
||||
target := &Instance{IsVLAN: true, VLANInterface: "eth0.42"}
|
||||
var wg sync.WaitGroup
|
||||
for range 100 {
|
||||
wg.Go(func() {
|
||||
if !transferLinkAttachmentOwnership("eth0.42", []*Instance{target}, true) {
|
||||
t.Error("transferLinkAttachmentOwnership() did not find target")
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if !target.vlanOwned.Load() {
|
||||
t.Fatal("target did not receive VLAN ownership")
|
||||
}
|
||||
}
|
||||
102
pkg/instance/links_linux_test.go
Normal file
102
pkg/instance/links_linux_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build linux
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// 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 TestCleanupLinkAttachmentsOnlyDeletesOwnedVLAN(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
preexists bool
|
||||
inUse bool
|
||||
}{
|
||||
{name: "owned VLAN", preexists: false},
|
||||
{name: "adopted VLAN", preexists: true},
|
||||
{name: "owned VLAN used by another Service", inUse: true},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
parent := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvattach0"}}
|
||||
if err := netlink.LinkAdd(parent); err != nil {
|
||||
t.Fatalf("creating parent interface: %v", err)
|
||||
}
|
||||
if err := netlink.LinkSetUp(parent); err != nil {
|
||||
t.Fatalf("bringing up parent interface: %v", err)
|
||||
}
|
||||
if test.preexists {
|
||||
vlan := &netlink.Vlan{LinkAttrs: netlink.LinkAttrs{Name: "kvattach0.42", ParentIndex: parent.Attrs().Index}, VlanId: 42}
|
||||
if err := netlink.LinkAdd(vlan); err != nil {
|
||||
t.Fatalf("creating existing VLAN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
instance := &Instance{}
|
||||
if err := instance.addVLAN(parent.Attrs().Name, 42); err != nil {
|
||||
t.Fatalf("adding VLAN attachment: %v", err)
|
||||
}
|
||||
if instance.vlanOwned.Load() == test.preexists {
|
||||
t.Fatalf("vlanOwned = %t, want %t", instance.vlanOwned.Load(), !test.preexists)
|
||||
}
|
||||
var remaining []*Instance
|
||||
if test.inUse {
|
||||
remaining = []*Instance{{IsVLAN: true, VLANInterface: instance.VLANInterface}}
|
||||
}
|
||||
if err := instance.CleanupLinkAttachments(remaining...); err != nil {
|
||||
t.Fatalf("cleaning attachments: %v", err)
|
||||
}
|
||||
if test.inUse {
|
||||
if !remaining[0].vlanOwned.Load() {
|
||||
t.Fatal("remaining Service did not receive VLAN cleanup ownership")
|
||||
}
|
||||
if _, err := netlink.LinkByName("kvattach0.42"); err != nil {
|
||||
t.Fatalf("VLAN was removed while a Service still used it: %v", err)
|
||||
}
|
||||
if err := remaining[0].CleanupLinkAttachments(); err != nil {
|
||||
t.Fatalf("cleaning transferred attachment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = netlink.LinkByName("kvattach0.42")
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if test.preexists && err != nil {
|
||||
t.Fatalf("adopted VLAN was removed: %v", err)
|
||||
}
|
||||
if !test.preexists && !errors.As(err, ¬Found) {
|
||||
t.Fatalf("owned VLAN remains after cleanup: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,9 @@ const (
|
||||
// Name of the service lease object
|
||||
ServiceLease = "kube-vip.io/leaseName"
|
||||
|
||||
// Versioned kube-vip ownership metadata stored on Kubernetes election Leases
|
||||
LeaseVIPs = "kube-vip.io/lease-vips"
|
||||
|
||||
// Forces kube-vip to use per service election for this particular service
|
||||
ForcePerServiceElection = "kube-vip.io/forcePerServiceElection"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package kubevip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
@@ -24,10 +25,23 @@ func (c *Config) Validate() error {
|
||||
if err := validateInstanceName(c.InstanceName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRoutingProtocol(c.RoutingProtocol); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoutingProtocol rejects values the kernel cannot represent: netlink
|
||||
// carries the address and route protocol in a single byte, so a larger value is
|
||||
// silently truncated on the wire and never matches on readback.
|
||||
func validateRoutingProtocol(protocol int) error {
|
||||
if protocol < 0 || protocol > math.MaxUint8 {
|
||||
return fmt.Errorf("routingProtocol %d is out of range, must be between 0 and %d", protocol, math.MaxUint8)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInstanceName(name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
|
||||
@@ -62,6 +62,30 @@ func TestValidate_InstanceName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RoutingProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unset", protocol: 0, wantErr: false},
|
||||
{name: "kube-vip default", protocol: 248, wantErr: false},
|
||||
{name: "maximum byte value", protocol: 255, wantErr: false},
|
||||
{name: "truncated on the wire", protocol: 256, wantErr: true},
|
||||
{name: "negative", protocol: -1, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &Config{RoutingProtocol: tt.protocol}
|
||||
err := config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceNameLimitReservesNftablesPrefixAndFamilySuffix(t *testing.T) {
|
||||
name := strings.Repeat("a", instanceNameMaxLength)
|
||||
if got := len(egressNftablesTablePrefix + name + egressNftablesTableSuffix); got != nftablesNameMaxLength {
|
||||
|
||||
102
pkg/kubevip/lease_annotations.go
Normal file
102
pkg/kubevip/lease_annotations.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const LeaseVIPsVersion = "v1"
|
||||
|
||||
type LeaseVIPsValue struct {
|
||||
Version string `json:"version"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
IFAProto int `json:"ifa_proto"`
|
||||
VIPs []LeaseVIP `json:"vips"`
|
||||
}
|
||||
|
||||
type LeaseVIP struct {
|
||||
Index int `json:"index"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func WithLeaseVIPs(annotations map[string]string, instanceName string, ifaProto int, vips []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(annotations)+1)
|
||||
for key, value := range annotations {
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(LeaseVIPsValue{
|
||||
Version: LeaseVIPsVersion,
|
||||
InstanceName: instanceName,
|
||||
IFAProto: ifaProto,
|
||||
VIPs: normalizeLeaseVIPs(vips),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode %s annotation: %w", LeaseVIPs, err)
|
||||
}
|
||||
result[LeaseVIPs] = string(encoded)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ParseLeaseVIPs(value string) (LeaseVIPsValue, error) {
|
||||
var parsed LeaseVIPsValue
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("decode %s annotation: %w", LeaseVIPs, err)
|
||||
}
|
||||
if parsed.Version != LeaseVIPsVersion {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("unsupported %s annotation version %q", LeaseVIPs, parsed.Version)
|
||||
}
|
||||
for index, vip := range parsed.VIPs {
|
||||
if vip.Index != index {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("invalid %s VIP index %d at position %d", LeaseVIPs, vip.Index, index)
|
||||
}
|
||||
address, err := parseLeaseVIP(vip.Value)
|
||||
if err != nil {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("invalid %s VIP at index %d: %w", LeaseVIPs, vip.Index, err)
|
||||
}
|
||||
parsed.VIPs[index].Value = address.String()
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeLeaseVIPs(values []string) []LeaseVIP {
|
||||
unique := make(map[netip.Addr]struct{})
|
||||
addresses := make([]netip.Addr, 0, len(values))
|
||||
for _, value := range values {
|
||||
for candidate := range strings.SplitSeq(value, ",") {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
address, err := parseLeaseVIP(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := unique[address]; exists {
|
||||
continue
|
||||
}
|
||||
unique[address] = struct{}{}
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
}
|
||||
// Sorting keeps the annotation byte-identical however callers happen to order VIPs.
|
||||
slices.SortFunc(addresses, netip.Addr.Compare)
|
||||
|
||||
result := make([]LeaseVIP, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
result = append(result, LeaseVIP{Index: len(result), Value: address.String()})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseLeaseVIP(value string) (netip.Addr, error) {
|
||||
address, err := netip.ParseAddr(value)
|
||||
if err == nil {
|
||||
return address.Unmap(), nil
|
||||
}
|
||||
prefix, prefixErr := netip.ParsePrefix(value)
|
||||
if prefixErr != nil {
|
||||
return netip.Addr{}, fmt.Errorf("parse address %q: %w", value, err)
|
||||
}
|
||||
return prefix.Addr().Unmap(), nil
|
||||
}
|
||||
78
pkg/kubevip/lease_annotations_test.go
Normal file
78
pkg/kubevip/lease_annotations_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package kubevip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWithLeaseVIPsEncodesVersionedInstanceOwnership(t *testing.T) {
|
||||
base := map[string]string{"example.test/preserved": "true", LeaseVIPs: "stale"}
|
||||
annotations, err := WithLeaseVIPs(base, "release_a", 248, []string{
|
||||
"2001:db8::10/128", "192.0.2.10", "192.0.2.10/32", "api.example.test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if annotations["example.test/preserved"] != "true" {
|
||||
t.Fatal("WithLeaseVIPs() dropped an existing annotation")
|
||||
}
|
||||
if base[LeaseVIPs] != "stale" {
|
||||
t.Fatal("WithLeaseVIPs() mutated the input annotations")
|
||||
}
|
||||
|
||||
value, err := ParseLeaseVIPs(annotations[LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if value.Version != LeaseVIPsVersion || value.InstanceName != "release_a" || value.IFAProto != 248 {
|
||||
t.Fatalf("Lease VIP metadata = %+v", value)
|
||||
}
|
||||
if len(value.VIPs) != 2 ||
|
||||
value.VIPs[0] != (LeaseVIP{Index: 0, Value: "192.0.2.10"}) ||
|
||||
value.VIPs[1] != (LeaseVIP{Index: 1, Value: "2001:db8::10"}) {
|
||||
t.Fatalf("Lease VIPs = %v, want indexed VIPs in canonical address order", value.VIPs)
|
||||
}
|
||||
}
|
||||
|
||||
// The annotation is rewritten whenever a node starts campaigning, so the encoding
|
||||
// has to be stable even when callers collect the same VIPs in a different order.
|
||||
func TestWithLeaseVIPsIsIndependentOfInputOrder(t *testing.T) {
|
||||
first, err := WithLeaseVIPs(nil, "release_a", 248, []string{
|
||||
"2001:db8::10", "192.0.2.10", "10.0.0.2", "10.0.0.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
second, err := WithLeaseVIPs(nil, "release_a", 248, []string{
|
||||
"10.0.0.10", "192.0.2.10", "2001:db8::10", "10.0.0.2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if first[LeaseVIPs] != second[LeaseVIPs] {
|
||||
t.Fatalf("annotation changed with input order:\n%s\n%s", first[LeaseVIPs], second[LeaseVIPs])
|
||||
}
|
||||
|
||||
value, err := ParseLeaseVIPs(first[LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
want := []string{"10.0.0.2", "10.0.0.10", "192.0.2.10", "2001:db8::10"}
|
||||
if len(value.VIPs) != len(want) {
|
||||
t.Fatalf("Lease VIPs = %v, want %v", value.VIPs, want)
|
||||
}
|
||||
for index, address := range want {
|
||||
if value.VIPs[index] != (LeaseVIP{Index: index, Value: address}) {
|
||||
t.Fatalf("Lease VIPs = %v, want %v", value.VIPs, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaseVIPsRejectsUnknownVersion(t *testing.T) {
|
||||
if _, err := ParseLeaseVIPs(`{"version":"v2","instance_name":"release_a","ifa_proto":248,"vips":[]}`); err == nil {
|
||||
t.Fatal("ParseLeaseVIPs() accepted an unknown version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaseVIPsRejectsOutOfOrderIndexes(t *testing.T) {
|
||||
if _, err := ParseLeaseVIPs(`{"version":"v1","instance_name":"release_a","ifa_proto":248,"vips":[{"index":1,"value":"192.0.2.10"}]}`); err == nil {
|
||||
t.Fatal("ParseLeaseVIPs() accepted an out-of-order VIP index")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -2,6 +2,7 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/election"
|
||||
"github.com/kube-vip/kube-vip/pkg/iptables"
|
||||
"github.com/kube-vip/kube-vip/pkg/k8s"
|
||||
@@ -52,8 +52,13 @@ type Manager struct {
|
||||
|
||||
// This channel is used to catch an OS signal and trigger a shutdown
|
||||
signalChan chan os.Signal
|
||||
killChan chan struct{}
|
||||
|
||||
sigint sync.Once
|
||||
sigint sync.Once
|
||||
killChanOnce sync.Once
|
||||
dumpWG sync.WaitGroup
|
||||
dumping atomic.Bool
|
||||
dump func(context.Context)
|
||||
|
||||
svcProcessor *services.Processor
|
||||
|
||||
@@ -86,6 +91,8 @@ type Manager struct {
|
||||
|
||||
// Will handle routes
|
||||
routeMgr *route.Manager
|
||||
|
||||
modeWorker func() worker.Worker
|
||||
}
|
||||
|
||||
// New will create a new managing object
|
||||
@@ -283,6 +290,10 @@ func New(ctx context.Context, configMap string, config *kubevip.Config) (*Manage
|
||||
|
||||
// Start will begin the Manager, which will start services and watch the configmap
|
||||
func (sm *Manager) Start(ctx context.Context) error {
|
||||
if err := runtimeFailure(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
|
||||
@@ -336,52 +347,67 @@ func (sm *Manager) Start(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
return sm.startMode(ctx)
|
||||
if err := sm.startMode(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return runtimeFailure(ctx)
|
||||
}
|
||||
|
||||
func runtimeFailure(ctx context.Context) error {
|
||||
err := context.Cause(ctx)
|
||||
if err == nil || errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Start will begin the Manager, which will start services and watch the configmap
|
||||
func (sm *Manager) startMode(ctx context.Context) error {
|
||||
var cpCluster *cluster.Cluster
|
||||
var err error
|
||||
|
||||
w := worker.New(sm.arpMgr, sm.intfMgr, sm.config, &sm.closing, sm.Kill,
|
||||
sm.svcProcessor, &sm.mutex, sm.clientSet, sm.bgpServer, sm.electionMgr,
|
||||
sm.leaseMgr, sm.routeMgr, sm.nodeLabelManager)
|
||||
w := sm.newModeWorker()
|
||||
|
||||
// use a Go context so we can tell the leaderelection code when we
|
||||
// want to step down
|
||||
wg := sync.WaitGroup{}
|
||||
modeCtx, cancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
|
||||
cancel()
|
||||
wg.Wait()
|
||||
w.Cleanup()
|
||||
cancel()
|
||||
log.Info("Shutting down Kube-Vip")
|
||||
}()
|
||||
|
||||
log.Info("starting Kube-vip Manager", "mode", w.Name())
|
||||
if err := w.Configure(modeCtx, &wg); err != nil {
|
||||
defer cancel()
|
||||
return fmt.Errorf("failed to configure %s mode: %w", w.Name(), err)
|
||||
}
|
||||
|
||||
if sm.config.EnableControlPlane {
|
||||
err = w.InitControlPlane()
|
||||
if err != nil {
|
||||
defer cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown function that will wait on this signal, unless we call it ourselves
|
||||
// Start the shutdown watcher before any slow startup API calls, so a
|
||||
// signal received during RecoverAddresses cancels modeCtx immediately
|
||||
// instead of waiting for it to return on its own.
|
||||
wg.Go(func() {
|
||||
sm.waitForShutdown(modeCtx, cancel, cpCluster)
|
||||
sm.waitForShutdown(modeCtx, cancel)
|
||||
})
|
||||
|
||||
if sm.config.EnableControlPlane || sm.config.EnableServices {
|
||||
if err := sm.svcProcessor.RecoverAddresses(modeCtx); err != nil {
|
||||
log.Warn("skipping kube-vip address recovery", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if sm.config.EnableControlPlane {
|
||||
wg.Go(func() {
|
||||
w.StartControlPlane(modeCtx, sm.electionMgr)
|
||||
if modeCtx.Err() == nil {
|
||||
sm.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -398,11 +424,11 @@ func (sm *Manager) startMode(ctx context.Context) error {
|
||||
// TODO: Deprecate the iptables code v1.2.x
|
||||
err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv4)
|
||||
if err != nil {
|
||||
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", "IPv4", "error", err)
|
||||
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", utils.IPv4Family, "error", err)
|
||||
}
|
||||
err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv6)
|
||||
if err != nil {
|
||||
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", "IPv6", "error", err)
|
||||
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", utils.IPv6Family, "error", err)
|
||||
}
|
||||
}
|
||||
w.ConfigureServices()
|
||||
@@ -416,43 +442,87 @@ func (sm *Manager) startMode(ctx context.Context) error {
|
||||
if utils.IsPanicError(err) {
|
||||
sm.Kill()
|
||||
return fmt.Errorf("failed to reconcile services, non-recoverable error: %w", err)
|
||||
} else {
|
||||
log.Error("failed to reconcile services, restarting", "error", err)
|
||||
}
|
||||
log.Error("failed to reconcile services, restarting", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-modeCtx.Done():
|
||||
return nil
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sm.config.EnableControlPlane {
|
||||
<-modeCtx.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *Manager) waitForShutdown(ctx context.Context, cancel context.CancelFunc, cpCluster *cluster.Cluster) {
|
||||
func (sm *Manager) newModeWorker() worker.Worker {
|
||||
if sm.modeWorker != nil {
|
||||
return sm.modeWorker()
|
||||
}
|
||||
return worker.New(sm.arpMgr, sm.intfMgr, sm.config, &sm.closing, sm.Kill,
|
||||
sm.svcProcessor, &sm.mutex, sm.clientSet, sm.bgpServer, sm.electionMgr,
|
||||
sm.leaseMgr, sm.routeMgr, sm.nodeLabelManager)
|
||||
}
|
||||
|
||||
func (sm *Manager) waitForShutdown(ctx context.Context, cancel context.CancelFunc) {
|
||||
defer sm.dumpWG.Wait()
|
||||
for {
|
||||
sig := <-sm.signalChan
|
||||
var sig os.Signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sm.killChannel():
|
||||
sm.shutdown(cancel)
|
||||
return
|
||||
case sig = <-sm.signalChan:
|
||||
}
|
||||
switch sig {
|
||||
case syscall.SIGUSR1:
|
||||
log.Info("Received SIGUSR1, dumping configuration")
|
||||
sm.dumpConfiguration(ctx)
|
||||
case syscall.SIGINT, syscall.SIGTERM:
|
||||
sm.closing.Store(true)
|
||||
log.Info("Received kube-vip termination, signaling shutdown")
|
||||
if cpCluster != nil {
|
||||
cpCluster.Stop()
|
||||
if sm.dumping.CompareAndSwap(false, true) {
|
||||
log.Info("Received SIGUSR1, dumping configuration")
|
||||
dump := sm.dump
|
||||
if dump == nil {
|
||||
dump = sm.dumpConfiguration
|
||||
}
|
||||
sm.dumpWG.Add(1)
|
||||
go func() {
|
||||
defer sm.dumpWG.Done()
|
||||
defer sm.dumping.Store(false)
|
||||
dump(ctx)
|
||||
}()
|
||||
}
|
||||
// Cancel the context, which will in turn cancel the leadership and all goroutines
|
||||
cancel()
|
||||
case syscall.SIGINT, syscall.SIGTERM:
|
||||
sm.shutdown(cancel)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *Manager) shutdown(cancel context.CancelFunc) {
|
||||
sm.closing.Store(true)
|
||||
log.Info("Received kube-vip termination, signaling shutdown")
|
||||
// Cancel the context, which will in turn cancel the leadership and all goroutines.
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (sm *Manager) Kill() {
|
||||
sm.sigint.Do(func() {
|
||||
sm.signalChan <- syscall.SIGINT
|
||||
close(sm.killChannel())
|
||||
})
|
||||
}
|
||||
|
||||
func (sm *Manager) killChannel() chan struct{} {
|
||||
sm.killChanOnce.Do(func() {
|
||||
sm.killChan = make(chan struct{})
|
||||
})
|
||||
return sm.killChan
|
||||
}
|
||||
|
||||
// normalizeNodeName ensures the local machine hostname conforms to
|
||||
// Kubernetes RFC1123 node naming conventions (lowercase).
|
||||
func normalizeNodeName(name string) string {
|
||||
|
||||
@@ -104,21 +104,18 @@ func (sm *Manager) dumpServicesSection(ctx context.Context) {
|
||||
fmt.Printf("Service Security Enabled: %t\n", sm.config.EnableServiceSecurity)
|
||||
|
||||
if sm.svcProcessor != nil {
|
||||
instances := sm.svcProcessor.ServiceInstances
|
||||
fmt.Printf("Kube-vip Active Service Instances: %d\n", len(instances))
|
||||
for i, inst := range instances {
|
||||
if inst.ServiceSnapshot != nil {
|
||||
svc := inst.ServiceSnapshot
|
||||
vipConfigs := ""
|
||||
for j, cfg := range svc.Status.LoadBalancer.Ingress {
|
||||
if j > 0 {
|
||||
vipConfigs += ", "
|
||||
}
|
||||
vipConfigs += cfg.IP
|
||||
services := sm.svcProcessor.ServiceSnapshots()
|
||||
fmt.Printf("Kube-vip Active Service Instances: %d\n", len(services))
|
||||
for i, svc := range services {
|
||||
vipConfigs := ""
|
||||
for j, cfg := range svc.Status.LoadBalancer.Ingress {
|
||||
if j > 0 {
|
||||
vipConfigs += ", "
|
||||
}
|
||||
fmt.Printf(" Service %d: %s/%s (Type: %s, VIPs: %s)\n",
|
||||
i+1, svc.Namespace, svc.Name, svc.Spec.Type, vipConfigs)
|
||||
vipConfigs += cfg.IP
|
||||
}
|
||||
fmt.Printf(" Service %d: %s/%s (Type: %s, VIPs: %s)\n",
|
||||
i+1, svc.Namespace, svc.Name, svc.Spec.Type, vipConfigs)
|
||||
}
|
||||
}
|
||||
if sm.clientSet != nil {
|
||||
@@ -132,8 +129,6 @@ func (sm *Manager) dumpServicesSection(ctx context.Context) {
|
||||
fmt.Println("Unable to retrieve all Services")
|
||||
} else {
|
||||
for x := range svcList.Items {
|
||||
|
||||
// Build all addresses
|
||||
vipConfigs := ""
|
||||
for j, cfg := range svcList.Items[x].Status.LoadBalancer.Ingress {
|
||||
if j > 0 {
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"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/manager/worker"
|
||||
"github.com/kube-vip/kube-vip/pkg/node/noop"
|
||||
"github.com/kube-vip/kube-vip/pkg/services"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type controlPlaneTestWorker struct {
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (w *controlPlaneTestWorker) Configure(context.Context, *sync.WaitGroup) error { return nil }
|
||||
func (w *controlPlaneTestWorker) InitControlPlane() error { return nil }
|
||||
func (w *controlPlaneTestWorker) ConfigureServices() {}
|
||||
func (w *controlPlaneTestWorker) StartServices(context.Context) error { return nil }
|
||||
func (w *controlPlaneTestWorker) Name() string { return "control-plane-test" }
|
||||
func (w *controlPlaneTestWorker) Cleanup() {}
|
||||
|
||||
func (w *controlPlaneTestWorker) StartControlPlane(ctx context.Context, _ *election.Manager) {
|
||||
close(w.started)
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func TestNormalizeNodeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -36,3 +64,185 @@ func TestNormalizeNodeName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartReturnsCancellationCause(t *testing.T) {
|
||||
cause := errors.New("default interface is down")
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(cause)
|
||||
|
||||
manager := &Manager{
|
||||
config: &kubevip.Config{},
|
||||
nodeLabelManager: noop.NewManager(),
|
||||
}
|
||||
err := manager.Start(ctx)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("Start() error = %v, want cancellation cause %v", err, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartModeControlPlaneOnlyWaitsForShutdown(t *testing.T) {
|
||||
config := &kubevip.Config{EnableControlPlane: true}
|
||||
w := &controlPlaneTestWorker{started: make(chan struct{})}
|
||||
manager := &Manager{
|
||||
config: config,
|
||||
signalChan: make(chan os.Signal),
|
||||
svcProcessor: services.NewServicesProcessor(config, nil, nil, nil, nil, nil, noop.NewManager(), nil, nil, nil),
|
||||
modeWorker: func() worker.Worker { return w },
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- manager.startMode(context.Background())
|
||||
}()
|
||||
select {
|
||||
case <-w.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control-plane worker did not start")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("control-plane-only mode returned before shutdown: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
manager.Kill()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("control-plane-only mode returned an error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control-plane-only mode did not stop after Kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillSignalsShutdownWhileSignalIsQueued(t *testing.T) {
|
||||
manager := &Manager{
|
||||
config: &kubevip.Config{},
|
||||
signalChan: make(chan os.Signal, 1),
|
||||
}
|
||||
manager.signalChan <- syscall.SIGUSR1
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
manager.waitForShutdown(ctx, cancel)
|
||||
close(shutdownDone)
|
||||
}()
|
||||
|
||||
returned := make(chan struct{})
|
||||
go func() {
|
||||
manager.Kill()
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Kill blocked while the OS signal channel was full")
|
||||
}
|
||||
select {
|
||||
case <-shutdownDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Kill did not signal shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillDoesNotBlockAfterShutdownWithPendingSignal(t *testing.T) {
|
||||
manager := &Manager{
|
||||
config: &kubevip.Config{},
|
||||
signalChan: make(chan os.Signal, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
manager.waitForShutdown(ctx, func() {})
|
||||
close(shutdownDone)
|
||||
}()
|
||||
select {
|
||||
case <-shutdownDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitForShutdown did not return after context cancellation")
|
||||
}
|
||||
|
||||
manager.signalChan <- syscall.SIGUSR1
|
||||
returned := make(chan struct{})
|
||||
go func() {
|
||||
manager.Kill()
|
||||
close(returned)
|
||||
}()
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Kill blocked after shutdown with a pending signal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForShutdownTracksAndCoalescesConfigurationDump(t *testing.T) {
|
||||
dumpStarted := make(chan struct{})
|
||||
releaseDump := make(chan struct{})
|
||||
var dumpCalls atomic.Int64
|
||||
manager := &Manager{
|
||||
signalChan: make(chan os.Signal, 3),
|
||||
dump: func(context.Context) {
|
||||
dumpCalls.Add(1)
|
||||
close(dumpStarted)
|
||||
<-releaseDump
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
manager.waitForShutdown(ctx, cancel)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
manager.signalChan <- syscall.SIGUSR1
|
||||
select {
|
||||
case <-dumpStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("configuration dump did not start")
|
||||
}
|
||||
manager.signalChan <- syscall.SIGUSR1
|
||||
manager.Kill()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("waitForShutdown returned before the configuration dump completed")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(releaseDump)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitForShutdown did not return after the configuration dump completed")
|
||||
}
|
||||
if got := dumpCalls.Load(); got != 1 {
|
||||
t.Fatalf("configuration dump calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForShutdownHandlesKillSignal(t *testing.T) {
|
||||
manager := &Manager{
|
||||
signalChan: make(chan os.Signal, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
manager.waitForShutdown(ctx, cancel)
|
||||
close(done)
|
||||
}()
|
||||
manager.Kill()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitForShutdown did not return after Kill")
|
||||
}
|
||||
if !manager.closing.Load() {
|
||||
t.Fatal("Kill did not mark the manager as closing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
115
pkg/manager/worker/common_test.go
Normal file
115
pkg/manager/worker/common_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
package networkinterface
|
||||
|
||||
import (
|
||||
log "log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
lock sync.Mutex
|
||||
interfaces map[string]*Link
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Lock sync.Mutex
|
||||
Intf netlink.Link
|
||||
mu sync.Mutex
|
||||
intf netlink.Link
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
@@ -23,19 +23,37 @@ func NewManager() *Manager {
|
||||
}
|
||||
|
||||
func (m *Manager) Get(intf netlink.Link) *Link {
|
||||
if l, ok := m.interfaces[intf.Attrs().Name]; ok {
|
||||
updated, err := netlink.LinkByName(l.Intf.Attrs().Name)
|
||||
if err != nil {
|
||||
log.Error("failed to get interface %q: %w", l.Intf.Attrs().Name, err)
|
||||
return nil
|
||||
}
|
||||
l.Intf = updated
|
||||
return l
|
||||
if intf == nil || intf.Attrs() == nil {
|
||||
return nil
|
||||
}
|
||||
result := &Link{
|
||||
Intf: intf,
|
||||
attrs := intf.Attrs()
|
||||
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
if link, ok := m.interfaces[attrs.Name]; ok {
|
||||
link.replace(intf)
|
||||
return link
|
||||
}
|
||||
|
||||
m.interfaces[intf.Attrs().Name] = result
|
||||
return result
|
||||
link := &Link{intf: intf}
|
||||
m.interfaces[attrs.Name] = link
|
||||
return link
|
||||
}
|
||||
|
||||
func (l *Link) WithInterface(run func(netlink.Link) error) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return run(l.intf)
|
||||
}
|
||||
|
||||
func (l *Link) replace(intf netlink.Link) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.intf = intf
|
||||
}
|
||||
|
||||
func (m *Manager) Len() int {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return len(m.interfaces)
|
||||
}
|
||||
|
||||
65
pkg/networkinterface/networkinterface_instance_test.go
Normal file
65
pkg/networkinterface/networkinterface_instance_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package networkinterface_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/networkinterface"
|
||||
"github.com/kube-vip/kube-vip/pkg/node/noop"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestManagerReconstructsProductionInstanceConcurrently(t *testing.T) {
|
||||
config := &kubevip.Config{Interface: "lo", ServicesInterface: "lo", VIPSubnet: "32", DisableServiceUpdates: true}
|
||||
manager := networkinterface.NewManager()
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default", UID: "service"},
|
||||
Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10"},
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(2)
|
||||
results := make(chan struct {
|
||||
instance *instance.Instance
|
||||
err error
|
||||
}, 2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-start
|
||||
instanceConfig := *config
|
||||
created, err := instance.NewInstance(context.Background(), service.DeepCopy(), &instanceConfig, manager,
|
||||
arp.NewManager(&instanceConfig), route.NewManager(), noop.NewManager(), &sync.WaitGroup{})
|
||||
results <- struct {
|
||||
instance *instance.Instance
|
||||
err error
|
||||
}{created, err}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
for range 2 {
|
||||
select {
|
||||
case result := <-results:
|
||||
if result.err != nil {
|
||||
t.Fatalf("NewInstance() error = %v", result.err)
|
||||
}
|
||||
if len(result.instance.Clusters) != 1 {
|
||||
t.Fatalf("cluster count = %d, want 1", len(result.instance.Clusters))
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for concurrent NewInstance calls")
|
||||
}
|
||||
}
|
||||
if got := manager.Len(); got != 1 {
|
||||
t.Fatalf("cached link count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
74
pkg/networkinterface/networkinterface_test.go
Normal file
74
pkg/networkinterface/networkinterface_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package networkinterface
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
func TestManagerGetReplacesChangedInterfaceIndex(t *testing.T) {
|
||||
manager := NewManager()
|
||||
firstInterface := dummyLink("eth0", 1)
|
||||
first := manager.Get(firstInterface)
|
||||
|
||||
if got := manager.Get(dummyLink("eth0", 1)); got != first {
|
||||
t.Fatal("Get returned a new link for the same interface generation")
|
||||
}
|
||||
|
||||
secondInterface := dummyLink("eth0", 2)
|
||||
second := manager.Get(secondInterface)
|
||||
if second != first {
|
||||
t.Fatal("Get replaced the shared link after the interface index changed")
|
||||
}
|
||||
var current netlink.Link
|
||||
if err := first.WithInterface(func(intf netlink.Link) error {
|
||||
current = intf
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("WithInterface() error = %v", err)
|
||||
}
|
||||
if current != secondInterface {
|
||||
t.Fatal("Get did not retain the new link generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetConcurrent(t *testing.T) {
|
||||
manager := NewManager()
|
||||
interfaces := []netlink.Link{dummyLink("eth0", 1), dummyLink("eth1", 2)}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan struct {
|
||||
index int
|
||||
link *Link
|
||||
}, 64)
|
||||
for index := range cap(results) {
|
||||
interfaceIndex := index % len(interfaces)
|
||||
wg.Go(func() {
|
||||
results <- struct {
|
||||
index int
|
||||
link *Link
|
||||
}{index: interfaceIndex, link: manager.Get(interfaces[interfaceIndex])}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var cached [2]*Link
|
||||
for result := range results {
|
||||
if result.link == nil {
|
||||
t.Fatal("concurrent interface lookup returned nil")
|
||||
}
|
||||
if cached[result.index] == nil {
|
||||
cached[result.index] = result.link
|
||||
} else if result.link != cached[result.index] {
|
||||
t.Fatalf("interface %d produced multiple cached Link objects", result.index)
|
||||
}
|
||||
}
|
||||
if cached[0] == cached[1] {
|
||||
t.Fatal("different interfaces shared one cached Link object")
|
||||
}
|
||||
}
|
||||
|
||||
func dummyLink(name string, index int) netlink.Link {
|
||||
return &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name, Index: index}}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ func (m *Manager) Add(object string, r route, precheck, update bool) error {
|
||||
if added {
|
||||
log.Debug("[RT] added route", "path", key, "object", object)
|
||||
}
|
||||
m.tracker[key] = itm
|
||||
}
|
||||
|
||||
itm.objects[object] = true
|
||||
@@ -112,6 +113,8 @@ func (m *Manager) Delete(object string, r route) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Clear() {
|
||||
m.mtx.Lock()
|
||||
defer m.mtx.Unlock()
|
||||
for _, itm := range m.tracker {
|
||||
if err := itm.route.DeleteRoute(); err != nil {
|
||||
log.Warn("[RT] failed to delete route", "err", err.Error())
|
||||
@@ -121,6 +124,8 @@ func (m *Manager) Clear() {
|
||||
}
|
||||
|
||||
func (m *Manager) Check(key string) bool {
|
||||
m.mtx.Lock()
|
||||
defer m.mtx.Unlock()
|
||||
_, exists := m.tracker[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -184,6 +186,47 @@ func Test_MultipleRoutesAddDel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddFailureDoesNotTrackRoute(t *testing.T) {
|
||||
m := NewManager()
|
||||
r := &mockRoute{hash: "failed-route", addErr: errors.New("add failed")}
|
||||
|
||||
if err := m.Add("service", r, false, false); err == nil {
|
||||
t.Fatal("Add error = nil, want route failure")
|
||||
}
|
||||
if m.Check(r.RouteHash()) {
|
||||
t.Fatal("failed route was tracked")
|
||||
}
|
||||
|
||||
r.addErr = nil
|
||||
if err := m.Add("service", r, false, false); err != nil {
|
||||
t.Fatalf("retry Add error = %v", err)
|
||||
}
|
||||
if !m.Check(r.RouteHash()) {
|
||||
t.Fatal("successful retry was not tracked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAndCheckAreSafeWithRouteUpdates(t *testing.T) {
|
||||
manager := NewManager()
|
||||
route := &mockRoute{hash: "concurrent-route", added: true}
|
||||
if err := manager.Add("service", route, false, false); err != nil {
|
||||
t.Fatalf("Add() error = %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 20 {
|
||||
wg.Go(func() {
|
||||
manager.Check(route.RouteHash())
|
||||
})
|
||||
}
|
||||
wg.Go(manager.Clear)
|
||||
wg.Wait()
|
||||
|
||||
if manager.Check(route.RouteHash()) {
|
||||
t.Fatal("route remained tracked after concurrent Clear")
|
||||
}
|
||||
}
|
||||
|
||||
type mockRoute struct {
|
||||
added bool
|
||||
addCalls int
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
210
pkg/servicecontext/servicecontext_test.go
Normal file
210
pkg/servicecontext/servicecontext_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
31
pkg/services/callback_test.go
Normal file
31
pkg/services/callback_test.go
Normal 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
838
pkg/services/election.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
596
pkg/services/election_test.go
Normal file
596
pkg/services/election_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{{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)
|
||||
}
|
||||
|
||||
@@ -191,14 +242,15 @@ func TestOnStoppedLeadingDoesNotDeleteReplacementContext(t *testing.T) {
|
||||
oldCtx := servicecontext.New(context.Background())
|
||||
replacementCtx := servicecontext.New(context.Background())
|
||||
p.svcMap.Store(service.UID, replacementCtx)
|
||||
replacementInstance := &instance.Instance{ServiceSnapshot: service.DeepCopy()}
|
||||
replacementInstance := &instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}
|
||||
p.ServiceInstances = []*instance.Instance{replacementInstance}
|
||||
|
||||
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)
|
||||
|
||||
602
pkg/services/processor_lock_test.go
Normal file
602
pkg/services/processor_lock_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
333
pkg/services/recovery_linux_test.go
Normal file
333
pkg/services/recovery_linux_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,109 +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 {
|
||||
for ip := range 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)
|
||||
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():
|
||||
if !p.updateDHCPAddress(ctx, svc, inst, index, ip, false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Debug("IPv4 update channel closed, stopping")
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
for ip := range inst.DHCPv4Client.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)
|
||||
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():
|
||||
if !p.updateDHCPAddress(ctx, svc, inst, index, ip, true) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Debug("IPv6 update channel closed, stopping")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !p.config.DisableServiceUpdates {
|
||||
@@ -278,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.
|
||||
@@ -315,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
|
||||
}
|
||||
@@ -335,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++
|
||||
}
|
||||
@@ -371,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 {
|
||||
@@ -425,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 {
|
||||
@@ -573,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 {
|
||||
@@ -670,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
|
||||
@@ -729,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()
|
||||
@@ -739,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
|
||||
@@ -815,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{
|
||||
@@ -957,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)
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
@@ -96,22 +210,29 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
|
||||
for event := range ch {
|
||||
metrics.CountServiceWatchEvent.With(prometheus.Labels{"type": string(event.Type)}).Add(1)
|
||||
|
||||
// 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.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
|
||||
}
|
||||
case watch.Deleted:
|
||||
if err := p.Delete(event, forcedOnly); err != nil {
|
||||
if utils.IsPanicError(err) {
|
||||
return fmt.Errorf("delete service error: %w", err)
|
||||
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)
|
||||
}
|
||||
log.Error("service watcher event failed", "type", event.Type, "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:
|
||||
@@ -133,6 +254,30 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ import (
|
||||
const (
|
||||
defaultValidLft = 60
|
||||
iptablesComment = "%s kube-vip load balancer IP"
|
||||
// Linux reserves IFA_PROTO values 0-3 for unspecified and kernel-generated
|
||||
// addresses; user-space protocols start at 4. This is only a safety floor,
|
||||
firstUserAddressProtocol = 4
|
||||
|
||||
DefaultMaskIPv4 = 32
|
||||
DefaultMaskIPv6 = 128
|
||||
@@ -77,8 +80,7 @@ type Network interface {
|
||||
|
||||
// network - This allows network configuration
|
||||
type network struct {
|
||||
mu sync.Mutex
|
||||
|
||||
mu sync.RWMutex
|
||||
address *netlink.Addr
|
||||
link *networkinterface.Link
|
||||
ports []v1.ServicePort
|
||||
@@ -168,6 +170,7 @@ func NewConfig(address string, iface string, loGlobalScope bool, subnet string,
|
||||
if err != nil {
|
||||
return networks, errors.Wrapf(err, "could not parse address '%s'", address)
|
||||
}
|
||||
markKubeVIPAddress(result.address, result.routingProtocol)
|
||||
|
||||
// set address as deprecated so it isn't used as source address according to RFC 3484
|
||||
result.address.PreferedLft = 0
|
||||
@@ -265,6 +268,7 @@ func NewConfig(address string, iface string, loGlobalScope bool, subnet string,
|
||||
if result.address, err = netlink.ParseAddr(fmt.Sprintf("%s/%s", ip, s)); err != nil {
|
||||
return networks, err
|
||||
}
|
||||
markKubeVIPAddress(result.address, result.routingProtocol)
|
||||
// set ValidLft so that the VIP expires if the DNS entry is updated, otherwise it'll be refreshed by the DNS prober
|
||||
result.address.ValidLft = defaultValidLft
|
||||
|
||||
@@ -310,7 +314,17 @@ func ListRoutesByDst(table int, dst *net.IPNet) ([]netlink.Route, error) {
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (configurator *network) PrepareRoute() *netlink.Route {
|
||||
func (configurator *network) PrepareRoute() (route *netlink.Route) {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
_ = configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
route = configurator.prepareRoute(intf)
|
||||
return nil
|
||||
})
|
||||
return route
|
||||
}
|
||||
|
||||
func (configurator *network) prepareRoute(intf netlink.Link) *netlink.Route {
|
||||
routeScope := netlink.SCOPE_UNIVERSE
|
||||
if configurator.routingTableType == unix.RTN_LOCAL {
|
||||
routeScope = netlink.SCOPE_LINK
|
||||
@@ -318,7 +332,7 @@ func (configurator *network) PrepareRoute() *netlink.Route {
|
||||
route := &netlink.Route{
|
||||
Scope: routeScope,
|
||||
Dst: configurator.address.IPNet,
|
||||
LinkIndex: configurator.link.Intf.Attrs().Index,
|
||||
LinkIndex: intf.Attrs().Index,
|
||||
Table: configurator.routeTable,
|
||||
Type: configurator.routingTableType,
|
||||
Protocol: netlink.RouteProtocol(configurator.routingProtocol),
|
||||
@@ -339,32 +353,33 @@ func NetlinkHash(r *netlink.Route) string {
|
||||
}
|
||||
|
||||
// AddRoute - Add an IP address to a route table
|
||||
func (configurator *network) AddRoute(precheck bool) (bool, error) {
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
route := configurator.PrepareRoute()
|
||||
|
||||
exists := false
|
||||
var err error
|
||||
if precheck {
|
||||
exists, err = configurator.routeExists(route)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to check route")
|
||||
func (configurator *network) AddRoute(precheck bool) (added bool, err error) {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
err = configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
route := configurator.prepareRoute(intf)
|
||||
exists := false
|
||||
if precheck {
|
||||
var existsErr error
|
||||
exists, existsErr = configurator.routeExists(intf, route)
|
||||
if existsErr != nil {
|
||||
return errors.Wrap(existsErr, "failed to check route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err := netlink.RouteAdd(route); err != nil {
|
||||
return false, errors.Wrap(err, "failed to add route")
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
if routeErr := netlink.RouteAdd(route); routeErr != nil {
|
||||
return errors.Wrap(routeErr, "failed to add route")
|
||||
}
|
||||
added = true
|
||||
return nil
|
||||
})
|
||||
return added, err
|
||||
}
|
||||
|
||||
func (configurator *network) routeExists(route *netlink.Route) (bool, error) {
|
||||
routes, err := netlink.RouteList(configurator.link.Intf, netlink.FAMILY_ALL)
|
||||
func (configurator *network) routeExists(intf netlink.Link, route *netlink.Route) (bool, error) {
|
||||
routes, err := netlink.RouteList(intf, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to list routes")
|
||||
}
|
||||
@@ -385,16 +400,18 @@ func (configurator *network) routeExists(route *netlink.Route) (bool, error) {
|
||||
// the same-prefix interface address is processed in the same netlink batch,
|
||||
// leaving the route in the kernel but never redistributed.
|
||||
func (configurator *network) ReplaceRoute() error {
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
route := configurator.PrepareRoute()
|
||||
configurator.reassertToggle = !configurator.reassertToggle
|
||||
if configurator.reassertToggle {
|
||||
route.Realm = 1
|
||||
} else {
|
||||
route.Realm = 2
|
||||
}
|
||||
return netlink.RouteReplace(route)
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
return configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
route := configurator.prepareRoute(intf)
|
||||
configurator.reassertToggle = !configurator.reassertToggle
|
||||
if configurator.reassertToggle {
|
||||
route.Realm = 1
|
||||
} else {
|
||||
route.Realm = 2
|
||||
}
|
||||
return netlink.RouteReplace(route)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRoute - Delete an IP address from a route table
|
||||
@@ -405,6 +422,8 @@ func (configurator *network) DeleteRoute() error {
|
||||
|
||||
// GetRoutes - Get an IP addresses from a route table
|
||||
func (configurator *network) getRoutes() (*[]netlink.Route, error) {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
routes, err := ListRoutesByDst(configurator.routeTable, configurator.address.IPNet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting routes: %w", err)
|
||||
@@ -443,12 +462,22 @@ func (configurator *network) shouldSkipDAD(override bool) bool {
|
||||
// precheck: if true, check if the IP already exists before adding
|
||||
// skipDAD: if true, set IFA_F_NODAD flag for IPv6 addresses to skip Duplicate Address Detection
|
||||
func (configurator *network) AddIP(precheck bool, skipDAD bool, minLifetime ...int) (bool, error) {
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
var added bool
|
||||
err := configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
var addErr error
|
||||
added, addErr = configurator.addIP(intf, precheck, skipDAD, minLifetime...)
|
||||
return addErr
|
||||
})
|
||||
return added, err
|
||||
}
|
||||
|
||||
func (configurator *network) addIP(intf netlink.Link, precheck bool, skipDAD bool, minLifetime ...int) (bool, error) {
|
||||
var existing *netlink.Addr
|
||||
var err error
|
||||
if precheck {
|
||||
if existing, err = configurator.IsSet(); err != nil {
|
||||
if existing, err = configurator.isSet(intf); err != nil {
|
||||
return false, errors.Wrap(err, "could not check if address exists")
|
||||
}
|
||||
}
|
||||
@@ -479,8 +508,8 @@ func (configurator *network) AddIP(precheck bool, skipDAD bool, minLifetime ...i
|
||||
}
|
||||
|
||||
log.Debug("replacing IP", "address", configurator.address)
|
||||
if err := netlink.AddrReplace(configurator.link.Intf, configurator.address); err != nil {
|
||||
return false, errors.Wrap(err, fmt.Sprintf("could not add ip to device %q", configurator.link.Intf.Attrs().Name))
|
||||
if err := netlink.AddrReplace(intf, configurator.address); err != nil {
|
||||
return false, errors.Wrap(err, fmt.Sprintf("could not add ip to device %q", intf.Attrs().Name))
|
||||
}
|
||||
|
||||
if configurator.nftables {
|
||||
@@ -518,7 +547,7 @@ func (configurator *network) configureNFTables() error {
|
||||
"ports", configurator.ports, "service-name", configurator.serviceName)
|
||||
|
||||
opt := nftables.TableFamilyIPv4
|
||||
if utils.IsIPv6(configurator.IP()) {
|
||||
if utils.IsIPv6(configurator.address.IP.String()) {
|
||||
opt = nftables.TableFamilyIPv6
|
||||
}
|
||||
|
||||
@@ -595,12 +624,13 @@ func (configurator *network) addNftablesRulesToLimitTrafficPorts(c *nfinternal.C
|
||||
}
|
||||
}
|
||||
|
||||
firstRule, err := insertCommonNFTablesRules(c, configurator.IP(), comment)
|
||||
vip := configurator.address.IP.String()
|
||||
firstRule, err := insertCommonNFTablesRules(c, vip, comment)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add common nftables rules: %w", err)
|
||||
}
|
||||
|
||||
if err := configurator.insertNFTablesRulesForServicePorts(c, configurator.IP(), comment, firstRule.Handle); err != nil {
|
||||
if err := configurator.insertNFTablesRulesForServicePorts(c, vip, comment, firstRule.Handle); err != nil {
|
||||
return fmt.Errorf("could not add nftables rules for service ports: %v", err)
|
||||
}
|
||||
|
||||
@@ -1057,10 +1087,19 @@ func (configurator *network) removeNftablesRuleToLimitTrafficPorts(c *nfinternal
|
||||
|
||||
// DeleteIP - Remove an IP address from the interface
|
||||
func (configurator *network) DeleteIP() (bool, error) {
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
var deleted bool
|
||||
err := configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
var deleteErr error
|
||||
deleted, deleteErr = configurator.deleteIP(intf)
|
||||
return deleteErr
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
result, err := configurator.IsSet()
|
||||
func (configurator *network) deleteIP(intf netlink.Link) (bool, error) {
|
||||
result, err := configurator.isSet(intf)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "ip check in DeleteIP failed")
|
||||
}
|
||||
@@ -1070,7 +1109,7 @@ func (configurator *network) DeleteIP() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err = netlink.AddrDel(configurator.link.Intf, configurator.address); err != nil {
|
||||
if err = netlink.AddrDel(intf, configurator.address); err != nil {
|
||||
return false, errors.Wrap(err, "could not delete ip")
|
||||
}
|
||||
|
||||
@@ -1140,7 +1179,8 @@ func (configurator *network) addIptablesRulesForMasquerade() error {
|
||||
|
||||
// TO DO: It seems it is not be possible to use google/nftables with IPVS due to lack of IPVS matcher in nft
|
||||
func (configurator *network) addNftablesRulesForMasquerade(c *nfinternal.Client, comment string) error {
|
||||
cmt := fmt.Sprintf("%s - IPVS, VIP %s, MARK %d", comment, configurator.IP(), configurator.IPVSMark())
|
||||
vip := configurator.address.IP.String()
|
||||
cmt := fmt.Sprintf("%s - IPVS, VIP %s, MARK %d", comment, vip, configurator.ipvsMark)
|
||||
|
||||
markChain := &nftables.Chain{
|
||||
Name: "ipvs_prerouting",
|
||||
@@ -1154,7 +1194,7 @@ func (configurator *network) addNftablesRulesForMasquerade(c *nfinternal.Client,
|
||||
|
||||
markChain = c.AddChain(markChain)
|
||||
|
||||
ip := net.ParseIP(configurator.IP())
|
||||
ip := net.ParseIP(vip)
|
||||
|
||||
if ip.To4() != nil {
|
||||
ip = ip.To4()
|
||||
@@ -1164,7 +1204,7 @@ func (configurator *network) addNftablesRulesForMasquerade(c *nfinternal.Client,
|
||||
|
||||
port := binaryutil.BigEndian.PutUint16(configurator.ipvsPort)
|
||||
|
||||
mark := binaryutil.NativeEndian.PutUint32(configurator.IPVSMark())
|
||||
mark := binaryutil.NativeEndian.PutUint32(configurator.ipvsMark)
|
||||
|
||||
markRule := &nftables.Rule{
|
||||
Table: markChain.Table,
|
||||
@@ -1286,7 +1326,7 @@ func (configurator *network) removeNftablesRulesForMasquerade(c *nfinternal.Clie
|
||||
if configurator.serviceName != "" {
|
||||
comment = fmt.Sprintf(iptablesComment, configurator.serviceName)
|
||||
}
|
||||
cmt := fmt.Sprintf("%s - IPVS, VIP %s, MARK %d", comment, configurator.IP(), configurator.IPVSMark())
|
||||
cmt := fmt.Sprintf("%s - IPVS, VIP %s, MARK %d", comment, configurator.address.IP.String(), configurator.ipvsMark)
|
||||
|
||||
r, err := c.FindRuleByComment(chain.Table, chain, cmt)
|
||||
if err != nil {
|
||||
@@ -1349,27 +1389,26 @@ func delMasqueradeRuleForVIP(ipt *iptables.IPTables, vip, comment string) error
|
||||
|
||||
// IsDADFAIL - Returns true if the address is IPv6 and has DADFAILED flag
|
||||
func (configurator *network) IsDADFAIL() bool {
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
|
||||
if configurator.address == nil || !utils.IsIPv6(configurator.address.IP.String()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get all the address
|
||||
addresses, err := netlink.AddrList(configurator.link.Intf, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the VIP and check if it is DADFAILED
|
||||
for _, address := range addresses {
|
||||
if address.IP.Equal(configurator.address.IP) && addressHasDADFAILEDFlag(address) {
|
||||
return true
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
var dadFailed bool
|
||||
_ = configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
if configurator.address == nil || !utils.IsIPv6(configurator.address.IP.String()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
addresses, err := netlink.AddrList(intf, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if address.IP.Equal(configurator.address.IP) && addressHasDADFAILEDFlag(address) {
|
||||
dadFailed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return dadFailed
|
||||
}
|
||||
|
||||
func addressHasDADFAILEDFlag(address netlink.Addr) bool {
|
||||
@@ -1378,6 +1417,16 @@ func addressHasDADFAILEDFlag(address netlink.Addr) bool {
|
||||
|
||||
// isSet - Check to see if VIP is set
|
||||
func (configurator *network) IsSet() (result *netlink.Addr, err error) {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
err = configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
result, err = configurator.isSet(intf)
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (configurator *network) isSet(intf netlink.Link) (result *netlink.Addr, err error) {
|
||||
var addresses []netlink.Addr
|
||||
|
||||
if configurator.address == nil {
|
||||
@@ -1388,7 +1437,7 @@ func (configurator *network) IsSet() (result *netlink.Addr, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
addresses, err = netlink.AddrList(configurator.link.Intf, 0)
|
||||
addresses, err = netlink.AddrList(intf, 0)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "could not list addresses")
|
||||
|
||||
@@ -1408,10 +1457,10 @@ func (configurator *network) IsSet() (result *netlink.Addr, err error) {
|
||||
func (configurator *network) SetIP(ip string) error {
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
return configurator.setIP(ip)
|
||||
}
|
||||
|
||||
configurator.link.Lock.Lock()
|
||||
defer configurator.link.Lock.Unlock()
|
||||
|
||||
func (configurator *network) setIP(ip string) error {
|
||||
if strings.Contains("/", ip) {
|
||||
return fmt.Errorf("ip should not contain CIDR notation got: %s", ip)
|
||||
}
|
||||
@@ -1443,7 +1492,7 @@ func (configurator *network) SetIP(ip string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configurator.address != nil && configurator.IsDNS() {
|
||||
if configurator.address != nil && configurator.dnsName != "" {
|
||||
addr.ValidLft = defaultValidLft
|
||||
} else {
|
||||
addr.ValidLft = math.MaxInt
|
||||
@@ -1453,9 +1502,94 @@ func (configurator *network) SetIP(ip string) error {
|
||||
addr.PreferedLft = 0
|
||||
|
||||
configurator.address = addr
|
||||
if configurator.routingProtocol != 0 {
|
||||
markKubeVIPAddress(configurator.address, configurator.routingProtocol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsKubeVIPAddress reports whether an address carries kube-vip's configured
|
||||
// protocol origin. IFA_PROTO is supported for both IPv4 and IPv6 on Linux 5.18+.
|
||||
func IsKubeVIPAddress(address netlink.Addr, protocol int) bool {
|
||||
return protocol >= firstUserAddressProtocol && address.Protocol == protocol
|
||||
}
|
||||
|
||||
// RetainedKubeVIPAddressKeys returns the tagged kernel addresses matching the
|
||||
// supplied VIPs. Callers use the keys with CleanupKubeVIPAddresses after they
|
||||
// determine which VIPs remain locally referenced.
|
||||
func RetainedKubeVIPAddressKeys(protocol int, vips map[string]struct{}) (map[string]struct{}, error) {
|
||||
retained := make(map[string]struct{})
|
||||
err := forEachKubeVIPAddress(protocol, func(_ netlink.Link, address netlink.Addr) error {
|
||||
if _, retain := vips[address.IP.String()]; retain {
|
||||
retained[addressKey(address)] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return retained, nil
|
||||
}
|
||||
|
||||
// CleanupKubeVIPAddresses removes kube-vip addresses not reasserted by this
|
||||
// process. The retained keys must come from successful AddrReplace operations.
|
||||
func CleanupKubeVIPAddresses(protocol int, retained map[string]struct{}) (int, error) {
|
||||
removed := 0
|
||||
err := forEachKubeVIPAddress(protocol, func(link netlink.Link, address netlink.Addr) error {
|
||||
key := addressKey(address)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if _, keep := retained[key]; keep {
|
||||
return nil
|
||||
}
|
||||
if err := netlink.AddrDel(link, &address); err != nil {
|
||||
return errors.Wrapf(err, "deleting kube-vip address %q from interface %q", address.IP, link.Attrs().Name)
|
||||
}
|
||||
removed++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func forEachKubeVIPAddress(protocol int, visit func(netlink.Link, netlink.Addr) error) error {
|
||||
links, err := netlink.LinkList()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "listing network links")
|
||||
}
|
||||
for _, link := range links {
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "listing addresses on interface %q", link.Attrs().Name)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if IsKubeVIPAddress(address, protocol) {
|
||||
if err := visit(link, address); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addressKey(address netlink.Addr) string {
|
||||
if address.LinkIndex <= 0 || address.IP == nil {
|
||||
return ""
|
||||
}
|
||||
prefixLength, _ := address.Mask.Size()
|
||||
return fmt.Sprintf("%d/%s/%d", address.LinkIndex, address.IP, prefixLength)
|
||||
}
|
||||
|
||||
func markKubeVIPAddress(address *netlink.Addr, protocol int) {
|
||||
if address != nil {
|
||||
address.Protocol = protocol
|
||||
}
|
||||
}
|
||||
|
||||
// SetServicePorts updates the service ports from the service
|
||||
// If you want to limit traffic to the VIP to only the service ports, add service ports to the network firstly.
|
||||
func (configurator *network) SetServicePorts(service *v1.Service) {
|
||||
@@ -1469,8 +1603,8 @@ func (configurator *network) SetServicePorts(service *v1.Service) {
|
||||
|
||||
// IP - return the IP Address
|
||||
func (configurator *network) IP() string {
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
|
||||
if configurator.address == nil || configurator.address.IP == nil {
|
||||
return ""
|
||||
@@ -1480,8 +1614,8 @@ func (configurator *network) IP() string {
|
||||
}
|
||||
|
||||
func (configurator *network) CIDR() string {
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
|
||||
if configurator.address == nil || configurator.address.IPNet == nil {
|
||||
return ""
|
||||
@@ -1492,24 +1626,30 @@ func (configurator *network) CIDR() string {
|
||||
|
||||
// IP - return the IP Address
|
||||
func (configurator *network) IPisLinkLocal() bool {
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
|
||||
return configurator.address.IP.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
// DNSName return the configured dnsName when use DNS
|
||||
func (configurator *network) DNSName() string {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.dnsName
|
||||
}
|
||||
|
||||
// IsDNS - when dnsName is configured
|
||||
func (configurator *network) IsDNS() bool {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.dnsName != ""
|
||||
}
|
||||
|
||||
// IsDDNS - return true if use dynamic dns
|
||||
func (configurator *network) IsDDNS() bool {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.isDDNS
|
||||
}
|
||||
|
||||
@@ -1518,12 +1658,19 @@ func (configurator *network) IsDDNS() bool {
|
||||
// it's expected that dynamic DNS should be configured so
|
||||
// the fqdn for apiserver endpoint is dDNSHostName.{LocalDomain}
|
||||
func (configurator *network) DDNSHostName() string {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return getHostName(configurator.dnsName)
|
||||
}
|
||||
|
||||
// Interface - return the Interface name
|
||||
func (configurator *network) Interface() string {
|
||||
return configurator.link.Intf.Attrs().Name
|
||||
var name string
|
||||
_ = configurator.link.WithInterface(func(intf netlink.Link) error {
|
||||
name = intf.Attrs().Name
|
||||
return nil
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
func GarbageCollect(adapter, address string, intfMgr *networkinterface.Manager) (found bool, err error) {
|
||||
@@ -1534,32 +1681,28 @@ func GarbageCollect(adapter, address string, intfMgr *networkinterface.Manager)
|
||||
}
|
||||
|
||||
l := intfMgr.Get(link)
|
||||
|
||||
l.Lock.Lock()
|
||||
defer l.Lock.Unlock()
|
||||
|
||||
// Get addresses on adapter
|
||||
addrs, err := netlink.AddrList(l.Intf, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Compare all addresses to new service address, and remove if needed
|
||||
for _, existing := range addrs {
|
||||
if existing.IP.String() == address {
|
||||
// We've found the existing address
|
||||
found = true
|
||||
// linting issue
|
||||
existing := existing
|
||||
if err = netlink.AddrDel(l.Intf, &existing); err != nil {
|
||||
return true, errors.Wrap(err, "could not delete ip")
|
||||
err = l.WithInterface(func(intf netlink.Link) error {
|
||||
addrs, listErr := netlink.AddrList(intf, netlink.FAMILY_ALL)
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
for _, existing := range addrs {
|
||||
if existing.IP.String() == address {
|
||||
found = true
|
||||
existing := existing
|
||||
if deleteErr := netlink.AddrDel(intf, &existing); deleteErr != nil {
|
||||
return errors.Wrap(deleteErr, "could not delete ip")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return // Didn't find the address on the adapter
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
|
||||
func (configurator *network) SetMask(mask string) error {
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
selectedMask := mask
|
||||
var err error
|
||||
|
||||
@@ -1567,8 +1710,12 @@ func (configurator *network) SetMask(mask string) error {
|
||||
return fmt.Errorf("no mask provided")
|
||||
}
|
||||
|
||||
if configurator.IP() != "" {
|
||||
selectedMask, err = SelectSubnet(configurator.IP(), mask)
|
||||
ip := ""
|
||||
if configurator.address != nil && configurator.address.IP != nil {
|
||||
ip = configurator.address.IP.String()
|
||||
}
|
||||
if ip != "" {
|
||||
selectedMask, err = SelectSubnet(ip, mask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select mask %q: %w", mask, err)
|
||||
}
|
||||
@@ -1584,8 +1731,8 @@ func (configurator *network) SetMask(mask string) error {
|
||||
size := DefaultMaskIPv4
|
||||
family := utils.IPv4Family
|
||||
|
||||
if configurator.IP() != "" {
|
||||
if utils.IsIPv6(configurator.IP()) {
|
||||
if ip != "" {
|
||||
if utils.IsIPv6(ip) {
|
||||
size = DefaultMaskIPv6
|
||||
family = utils.IPv6Family
|
||||
}
|
||||
@@ -1600,20 +1747,29 @@ func (configurator *network) SetMask(mask string) error {
|
||||
return fmt.Errorf("failed to create mask /%d", m)
|
||||
}
|
||||
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
|
||||
configurator.address.Mask = toSet
|
||||
return nil
|
||||
}
|
||||
|
||||
func (configurator *network) SetHasEndpoints(value bool) {
|
||||
log.Debug("setting HasEndpoints", "ip", configurator.IP(), "value", value)
|
||||
configurator.mu.Lock()
|
||||
defer configurator.mu.Unlock()
|
||||
ip := ""
|
||||
if configurator.address != nil && configurator.address.IP != nil {
|
||||
ip = configurator.address.IP.String()
|
||||
}
|
||||
log.Debug("setting HasEndpoints", "ip", ip, "value", value)
|
||||
configurator.hasEndpoints = value
|
||||
}
|
||||
|
||||
func (configurator *network) HasEndpoints() bool {
|
||||
log.Debug("getting HasEndpoints", "ip", configurator.IP(), "value", configurator.hasEndpoints)
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
ip := ""
|
||||
if configurator.address != nil && configurator.address.IP != nil {
|
||||
ip = configurator.address.IP.String()
|
||||
}
|
||||
log.Debug("getting HasEndpoints", "ip", ip, "value", configurator.hasEndpoints)
|
||||
return configurator.hasEndpoints
|
||||
}
|
||||
|
||||
@@ -1622,14 +1778,20 @@ func (configurator *network) ARPName() string {
|
||||
}
|
||||
|
||||
func (configurator *network) GetPossibleSubnets() string {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.possibleSubnets
|
||||
}
|
||||
|
||||
func (configurator *network) DHCPFamily() string {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.dhcpFamily
|
||||
}
|
||||
|
||||
func (configurator *network) IPVSMark() uint32 {
|
||||
configurator.mu.RLock()
|
||||
defer configurator.mu.RUnlock()
|
||||
return configurator.ipvsMark
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ func TestAddIPPerCallDADSkipDoesNotPersist(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
interfaceManager := networkinterface.NewManager()
|
||||
configurator := &network{
|
||||
address: address,
|
||||
link: &networkinterface.Link{
|
||||
Intf: &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kube-vip-dad-test"}},
|
||||
},
|
||||
link: interfaceManager.Get(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kube-vip-dad-test"}}),
|
||||
}
|
||||
|
||||
// The netlink operation may fail without CAP_NET_ADMIN, but the address
|
||||
|
||||
220
pkg/vip/address_label_linux_test.go
Normal file
220
pkg/vip/address_label_linux_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
//go:build linux
|
||||
|
||||
package vip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
const kubeVIPProtocol = 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 TestAddressProtocolRoundTripsThroughNetlink(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)
|
||||
}
|
||||
}()
|
||||
|
||||
link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvproto0"}}
|
||||
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)
|
||||
}
|
||||
|
||||
parsed, err := netlink.ParseAddr("192.0.2.10/32")
|
||||
if err != nil {
|
||||
t.Fatalf("parsing IPv4 address: %v", err)
|
||||
}
|
||||
markKubeVIPAddress(parsed, kubeVIPProtocol)
|
||||
if err := netlink.AddrReplace(link, parsed); err != nil {
|
||||
t.Fatalf("adding IPv4 address with protocol: %v", err)
|
||||
}
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
t.Fatalf("listing addresses: %v", err)
|
||||
}
|
||||
for _, configured := range addresses {
|
||||
if configured.IP.Equal(net.ParseIP("192.0.2.10")) {
|
||||
if !IsKubeVIPAddress(configured, kubeVIPProtocol) {
|
||||
t.Fatalf("configured address = %+v, want kube-vip protocol", configured)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("IPv4 address with kube-vip protocol was not configured")
|
||||
}
|
||||
|
||||
func TestKubeVIPAddressProtocolRoundTripsThroughIPv6Netlink(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)
|
||||
}
|
||||
}()
|
||||
|
||||
link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvproto1"}}
|
||||
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("2001:db8::10/128")
|
||||
if err != nil {
|
||||
t.Fatalf("parsing IPv6 address: %v", err)
|
||||
}
|
||||
markKubeVIPAddress(address, kubeVIPProtocol)
|
||||
if err := netlink.AddrReplace(link, address); err != nil {
|
||||
t.Fatalf("adding IPv6 address with protocol: %v", err)
|
||||
}
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
t.Fatalf("listing IPv6 addresses: %v", err)
|
||||
}
|
||||
for _, configured := range addresses {
|
||||
if configured.IP.Equal(net.ParseIP("2001:db8::10")) {
|
||||
if !IsKubeVIPAddress(configured, kubeVIPProtocol) {
|
||||
t.Fatalf("configured IPv6 address = %+v, want kube-vip protocol", configured)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("configured IPv6 addresses = %+v, want 2001:db8::10", addresses)
|
||||
}
|
||||
|
||||
func TestCleanupKubeVIPAddressesRemovesOnlyUnretainedProtocolAddresses(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)
|
||||
}
|
||||
}()
|
||||
|
||||
link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvproto2"}}
|
||||
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)
|
||||
}
|
||||
for _, input := range []struct {
|
||||
cidr string
|
||||
protocol int
|
||||
}{
|
||||
{cidr: "192.0.2.10/32", protocol: kubeVIPProtocol},
|
||||
{cidr: "192.0.2.11/32", protocol: kubeVIPProtocol},
|
||||
{cidr: "192.0.2.12/32", protocol: 0},
|
||||
{cidr: "192.0.2.13/32", protocol: kubeVIPProtocol + 1},
|
||||
{cidr: "2001:db8::10/128", protocol: kubeVIPProtocol},
|
||||
{cidr: "2001:db8::11/128", protocol: kubeVIPProtocol},
|
||||
} {
|
||||
address, err := netlink.ParseAddr(input.cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing address: %v", err)
|
||||
}
|
||||
markKubeVIPAddress(address, input.protocol)
|
||||
if err := netlink.AddrReplace(link, address); err != nil {
|
||||
t.Fatalf("adding address %s: %v", input.cidr, err)
|
||||
}
|
||||
}
|
||||
|
||||
retained, err := netlink.ParseAddr("192.0.2.10/32")
|
||||
if err != nil {
|
||||
t.Fatalf("parsing retained address: %v", err)
|
||||
}
|
||||
retained.LinkIndex = link.Attrs().Index
|
||||
retainedIPv6, err := netlink.ParseAddr("2001:db8::10/128")
|
||||
if err != nil {
|
||||
t.Fatalf("parsing retained IPv6 address: %v", err)
|
||||
}
|
||||
retainedIPv6.LinkIndex = link.Attrs().Index
|
||||
removed, err := CleanupKubeVIPAddresses(kubeVIPProtocol, map[string]struct{}{
|
||||
addressKey(*retained): {},
|
||||
addressKey(*retainedIPv6): {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cleaning kube-vip addresses: %v", err)
|
||||
}
|
||||
if removed != 2 {
|
||||
t.Fatalf("removed = %d, want 2", removed)
|
||||
}
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
t.Fatalf("listing remaining addresses: %v", err)
|
||||
}
|
||||
wantAddresses := map[string]bool{
|
||||
"192.0.2.10": false,
|
||||
"192.0.2.12": false,
|
||||
"192.0.2.13": false,
|
||||
"2001:db8::10": false,
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if _, wanted := wantAddresses[address.IP.String()]; wanted {
|
||||
wantAddresses[address.IP.String()] = true
|
||||
}
|
||||
}
|
||||
for address, found := range wantAddresses {
|
||||
if !found {
|
||||
t.Fatalf("remaining addresses = %+v, missing %s", addresses, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,32 @@ package vip
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/networkinterface"
|
||||
)
|
||||
|
||||
func TestNewConfigTagsStaticAndUpdatedAddresses(t *testing.T) {
|
||||
const protocol = 248
|
||||
networks, err := NewConfig("192.0.2.10", "lo", false, "32", false, "", false, false, 0, 0, protocol,
|
||||
"", "", "", false, 0, false, networkinterface.NewManager(), false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConfig() error = %v", err)
|
||||
}
|
||||
configured, ok := networks[0].(*network)
|
||||
if !ok {
|
||||
t.Fatalf("network type = %T, want *network", networks[0])
|
||||
}
|
||||
if configured.address.Protocol != protocol {
|
||||
t.Fatalf("static address protocol = %d, want %d", configured.address.Protocol, protocol)
|
||||
}
|
||||
if err := configured.SetIP("192.0.2.11"); err != nil {
|
||||
t.Fatalf("SetIP() error = %v", err)
|
||||
}
|
||||
if configured.address.Protocol != protocol {
|
||||
t.Fatalf("updated address protocol = %d, want %d", configured.address.Protocol, protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDAD(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -27,12 +27,24 @@ type DHCPv4Client struct {
|
||||
initRebootFlag bool
|
||||
requestedIP net.IP
|
||||
broadcastFlag bool
|
||||
stopChan chan struct{} // used as a signal to release the IP and stop the dhcp client daemon
|
||||
releasedChan chan struct{} // indicate that the IP has been released
|
||||
stopChan chan struct{} // is used by external clients to stop DHCP
|
||||
errorChan chan error // indicates there was an error on the IP request
|
||||
ipChan chan string
|
||||
backoffAttempts uint
|
||||
stop sync.Once
|
||||
stopOnce sync.Once
|
||||
mtx sync.RWMutex
|
||||
}
|
||||
|
||||
func (c *DHCPv4Client) storeLease(lease *nclient4.Lease) {
|
||||
c.mtx.Lock()
|
||||
defer c.mtx.Unlock()
|
||||
c.lease = lease
|
||||
}
|
||||
|
||||
func (c *DHCPv4Client) loadLease() *nclient4.Lease {
|
||||
c.mtx.RLock()
|
||||
defer c.mtx.RUnlock()
|
||||
return c.lease
|
||||
}
|
||||
|
||||
// NewDHCPv4Client returns a new DHCP Client.
|
||||
@@ -40,7 +52,6 @@ func NewDHCPv4Client(iface *net.Interface, initRebootFlag bool, requestedIP stri
|
||||
return &DHCPv4Client{
|
||||
iface: iface,
|
||||
stopChan: make(chan struct{}),
|
||||
releasedChan: make(chan struct{}),
|
||||
errorChan: make(chan error),
|
||||
initRebootFlag: initRebootFlag,
|
||||
requestedIP: net.ParseIP(requestedIP),
|
||||
@@ -57,11 +68,13 @@ func (c *DHCPv4Client) WithHostName(hostname string) DHCPClient {
|
||||
|
||||
// Stop state-transition process and close dhcp client
|
||||
func (c *DHCPv4Client) Stop() {
|
||||
c.stop.Do(func() {
|
||||
close(c.ipChan)
|
||||
c.close()
|
||||
}
|
||||
|
||||
func (c *DHCPv4Client) close() {
|
||||
c.stopOnce.Do(func() {
|
||||
close(c.stopChan)
|
||||
})
|
||||
<-c.releasedChan
|
||||
}
|
||||
|
||||
// Gets the IPChannel for consumption
|
||||
@@ -129,80 +142,93 @@ func (c *DHCPv4Client) ErrorChannel() chan error {
|
||||
// ----------
|
||||
// Figure: State-transition diagram for DHCP clients
|
||||
func (c *DHCPv4Client) Start(ctx context.Context) error {
|
||||
dhcpCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
lease, err := c.requestWithBackoff(dhcpCtx)
|
||||
lease, err := c.requestWithBackoff(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DHCPv4 client failed: %w", err)
|
||||
}
|
||||
|
||||
c.initRebootFlag = false
|
||||
c.lease = lease
|
||||
|
||||
// Set up two ticker to renew/rebind regularly
|
||||
t1Timeout := c.lease.ACK.IPAddressLeaseTime(defaultDHCPRenew) / 2
|
||||
t2Timeout := (c.lease.ACK.IPAddressLeaseTime(defaultDHCPRenew) / 8) * 7
|
||||
c.storeLease(lease)
|
||||
|
||||
// Set up two timers to renew/rebind regularly
|
||||
t1Timeout, t2Timeout := getLeaseTimeouts(lease)
|
||||
log.Debug("[DHCPv4] timeouts", "timeout1", t1Timeout, "timeout2", t2Timeout)
|
||||
t1, t2 := time.NewTicker(t1Timeout), time.NewTicker(t2Timeout)
|
||||
t1, t2 := time.NewTimer(t1Timeout), time.NewTimer(t2Timeout)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return c.killProcessing(t1, t2)
|
||||
case <-ctx.Done():
|
||||
c.close()
|
||||
return c.killProcessing(t1, t2)
|
||||
case <-t1.C:
|
||||
// renew is a unicast request of the IP renewal
|
||||
// A point on renew is: the library does not return the right message (NAK)
|
||||
// on renew error due to IP Change, but instead it returns a different error
|
||||
// This way there's not much to do other than log and continue, as the renew error
|
||||
// may be an offline server, or may be an incorrect package match
|
||||
lease, err := c.renew(dhcpCtx)
|
||||
lease, err := c.renew(ctx)
|
||||
if err == nil {
|
||||
c.lease = lease
|
||||
c.storeLease(lease)
|
||||
t1Timeout, t2Timeout = getLeaseTimeouts(lease)
|
||||
log.Info("[DHCPv4] renew", "lease", lease)
|
||||
t2.Reset(t2Timeout)
|
||||
} else {
|
||||
log.Error("[DHCPv4] renew failed", "err", err)
|
||||
}
|
||||
t1.Reset(t1Timeout)
|
||||
case <-t2.C:
|
||||
// rebind is just like a request, but forcing to provide a new IP address
|
||||
lease, err := c.request(dhcpCtx, true)
|
||||
lease, err := c.request(ctx, true)
|
||||
if err == nil {
|
||||
c.lease = lease
|
||||
c.storeLease(lease)
|
||||
t1Timeout, t2Timeout = getLeaseTimeouts(lease)
|
||||
log.Info("[DHCPv4] rebind", "lease", lease)
|
||||
} else {
|
||||
if _, ok := err.(*nclient4.ErrNak); !ok {
|
||||
t1.Stop()
|
||||
t2.Stop()
|
||||
log.Error("[DHCPv4] rebind failed", "err", err)
|
||||
}
|
||||
log.Warn("[DHCPv4] ip may have changed", "ip", c.lease.ACK.YourIPAddr, "err", err)
|
||||
lease = c.loadLease()
|
||||
log.Warn("[DHCPv4] ip may have changed", "ip", lease.ACK.YourIPAddr, "err", err)
|
||||
c.initRebootFlag = false
|
||||
lease, backoffErr := c.requestWithBackoff(dhcpCtx)
|
||||
lease, backoffErr := c.requestWithBackoff(ctx)
|
||||
if backoffErr != nil {
|
||||
log.Error("[DHCPv4] failed to reacquire lease", "err", backoffErr)
|
||||
continue
|
||||
}
|
||||
c.lease = lease
|
||||
c.storeLease(lease)
|
||||
t1Timeout, t2Timeout = getLeaseTimeouts(lease)
|
||||
}
|
||||
t1.Reset(t1Timeout)
|
||||
t2.Reset(t2Timeout)
|
||||
|
||||
case <-c.stopChan:
|
||||
// release is a unicast request of the IP release.
|
||||
var err error
|
||||
if err = c.release(); err != nil {
|
||||
log.Error("[DHCPv4] release lease failed", "lease", lease, "err", err)
|
||||
} else {
|
||||
log.Info("[DHCPv4] release", "lease", lease)
|
||||
}
|
||||
t1.Stop()
|
||||
t2.Stop()
|
||||
|
||||
close(c.releasedChan)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLeaseTimeouts(lease *nclient4.Lease) (time.Duration, time.Duration) {
|
||||
t1Timeout, t2Timeout := lease.ACK.IPAddressLeaseTime(defaultDHCPRenew)/2, (lease.ACK.IPAddressLeaseTime(defaultDHCPRenew)/8)*7
|
||||
log.Debug("[DHCPv4] timeouts", "address", lease.ACK.YourIPAddr.String(), "T1", t1Timeout, "T2", t2Timeout)
|
||||
return t1Timeout, t2Timeout
|
||||
}
|
||||
|
||||
func (c *DHCPv4Client) killProcessing(t1, t2 *time.Timer) error {
|
||||
// release is a unicast request of the IP release.
|
||||
var err error
|
||||
lease := c.loadLease()
|
||||
if lease != nil {
|
||||
if err = c.release(); err != nil {
|
||||
log.Error("[DHCPv4] release lease failed", "lease", lease, "err", err)
|
||||
} else {
|
||||
log.Info("[DHCPv4] release", "lease", lease)
|
||||
}
|
||||
}
|
||||
t1.Stop()
|
||||
t2.Stop()
|
||||
return err
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// | |INIT-REBOOT | RENEWING |REBINDING |
|
||||
// --------------------------------------------------------
|
||||
@@ -225,28 +251,44 @@ func (c *DHCPv4Client) requestWithBackoff(ctx context.Context) (*nclient4.Lease,
|
||||
|
||||
log.Debug("[DHCPv4]", "attempts", c.backoffAttempts)
|
||||
|
||||
RequestLoop:
|
||||
for {
|
||||
log.Debug("[DHCPv4] trying to get a new IP", "attempt", backoff.Attempt()+1)
|
||||
lease, err = c.request(ctx, false)
|
||||
if err != nil {
|
||||
dur := backoff.Duration()
|
||||
if c.backoffAttempts > 0 && backoff.Attempt() > float64(c.backoffAttempts)-1 {
|
||||
errMsg := fmt.Errorf("failed to get an IPv4 address after %d attempt(s), giving up, error: %s", c.backoffAttempts, err.Error())
|
||||
log.Error(fmt.Sprintf("[DHCPv4] %s", errMsg.Error()))
|
||||
c.errorChan <- errMsg
|
||||
return nil, errMsg
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("[DHCPv4] context error: %w", ctx.Err())
|
||||
default:
|
||||
log.Debug("[DHCPv4] trying to get a new IP", "attempt", backoff.Attempt()+1)
|
||||
lease, err = c.request(ctx, false)
|
||||
if err != nil {
|
||||
dur := backoff.Duration()
|
||||
|
||||
if c.backoffAttempts > 0 && backoff.Attempt() > float64(c.backoffAttempts)-1 {
|
||||
errMsg := fmt.Errorf("failed to get an IPv4 address after %d attempt(s), giving up, error: %s", c.backoffAttempts, err.Error())
|
||||
log.Error(fmt.Sprintf("[DHCPv4] %s", errMsg.Error()))
|
||||
c.errorChan <- errMsg
|
||||
return nil, errMsg
|
||||
}
|
||||
log.Error("[DHCPv4] request failed", "attempt", backoff.Attempt(), "err", err.Error(), "waiting", dur)
|
||||
t := time.NewTimer(dur)
|
||||
select {
|
||||
case <-t.C:
|
||||
t.Stop()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue RequestLoop
|
||||
}
|
||||
log.Error("[DHCPv4] request failed", "attempt", backoff.Attempt(), "err", err.Error(), "waiting", dur)
|
||||
time.Sleep(dur)
|
||||
continue
|
||||
backoff.Reset()
|
||||
break RequestLoop
|
||||
}
|
||||
backoff.Reset()
|
||||
break
|
||||
}
|
||||
|
||||
if c.ipChan != nil {
|
||||
log.Debug("[DHCPv4] using channel")
|
||||
c.ipChan <- lease.ACK.YourIPAddr.String()
|
||||
// Nothing closes ipChan, so never block on a consumer that already stopped.
|
||||
select {
|
||||
case c.ipChan <- lease.ACK.YourIPAddr.String():
|
||||
case <-c.stopChan:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
return lease, nil
|
||||
@@ -296,7 +338,12 @@ func (c *DHCPv4Client) release() error {
|
||||
defer dhclient.Close()
|
||||
|
||||
// TODO modify lease
|
||||
return dhclient.Release(c.lease)
|
||||
err = dhclient.Release(c.lease)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DHCPv4 release failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DHCPv4Client) renew(ctx context.Context) (*nclient4.Lease, error) {
|
||||
|
||||
@@ -23,6 +23,9 @@ func init() {
|
||||
}
|
||||
|
||||
type DHCPv6ClientManager struct {
|
||||
// mu guards clients and the reference counts of its entries together, so a
|
||||
// concurrent Add cannot join a client that Delete is already retiring.
|
||||
mu sync.Mutex
|
||||
clients map[string]*DHCPv6InternalClient
|
||||
}
|
||||
|
||||
@@ -33,17 +36,17 @@ func NewDHCPv6ClientManager() *DHCPv6ClientManager {
|
||||
}
|
||||
|
||||
func (m *DHCPv6ClientManager) Get(iface string) *DHCPv6InternalClient {
|
||||
c, exists := m.clients[iface]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.clients[iface]
|
||||
}
|
||||
|
||||
func (m *DHCPv6ClientManager) Add(iface string) (*DHCPv6InternalClient, error) {
|
||||
c := m.Get(iface)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if c != nil {
|
||||
if c := m.clients[iface]; c != nil {
|
||||
c.references.Add(1)
|
||||
return c, nil
|
||||
}
|
||||
@@ -57,15 +60,16 @@ func (m *DHCPv6ClientManager) Add(iface string) (*DHCPv6InternalClient, error) {
|
||||
}
|
||||
|
||||
func (m *DHCPv6ClientManager) Delete(iface string) {
|
||||
c := m.Get(iface)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if c != nil {
|
||||
c.references.Add(-1)
|
||||
ref := c.references.Load()
|
||||
if ref < 1 {
|
||||
c.client.Close()
|
||||
delete(m.clients, iface)
|
||||
}
|
||||
c := m.clients[iface]
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.references.Add(-1) < 1 {
|
||||
c.client.Close()
|
||||
delete(m.clients, iface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,13 +99,25 @@ type DHCPv6Client struct {
|
||||
initRebootFlag bool
|
||||
requestedIP net.IP
|
||||
stopChan chan struct{} // used as a signal to release the IP and stop the dhcp client daemon
|
||||
releasedChan chan struct{} // indicate that the IP has been released
|
||||
errorChan chan error // indicates there was an error on the IP request
|
||||
ipChan chan string
|
||||
ic *DHCPv6InternalClient
|
||||
addr *dhcpv6.OptIAAddress
|
||||
backoffAttempts uint
|
||||
stop sync.Once
|
||||
mtx sync.RWMutex
|
||||
}
|
||||
|
||||
func (c *DHCPv6Client) storeAddr(addr *dhcpv6.OptIAAddress) {
|
||||
c.mtx.Lock()
|
||||
defer c.mtx.Unlock()
|
||||
c.addr = addr
|
||||
}
|
||||
|
||||
func (c *DHCPv6Client) loadAddr() *dhcpv6.OptIAAddress {
|
||||
c.mtx.RLock()
|
||||
defer c.mtx.RUnlock()
|
||||
return c.addr
|
||||
}
|
||||
|
||||
// NewDHCPv6Client returns a new DHCP6 Client.
|
||||
@@ -120,7 +136,6 @@ func NewDHCPv6Client(iface *net.Interface, parent netlink.Link, initRebootFlag b
|
||||
iface: iface,
|
||||
managerKey: name,
|
||||
stopChan: make(chan struct{}),
|
||||
releasedChan: make(chan struct{}),
|
||||
errorChan: make(chan error),
|
||||
initRebootFlag: initRebootFlag,
|
||||
requestedIP: net.ParseIP(requestedIP),
|
||||
@@ -137,11 +152,14 @@ func (c *DHCPv6Client) WithHostName(hostname string) DHCPClient {
|
||||
|
||||
// Stop state-transition process and close dhcp client
|
||||
func (c *DHCPv6Client) Stop() {
|
||||
c.close()
|
||||
}
|
||||
|
||||
// Close dhcp client channels
|
||||
func (c *DHCPv6Client) close() {
|
||||
c.stop.Do(func() {
|
||||
close(c.ipChan)
|
||||
close(c.stopChan)
|
||||
})
|
||||
<-c.releasedChan
|
||||
dhcpv6ClientManager.Delete(c.managerKey)
|
||||
}
|
||||
|
||||
@@ -156,27 +174,27 @@ func (c *DHCPv6Client) ErrorChannel() chan error {
|
||||
}
|
||||
|
||||
func (c *DHCPv6Client) Start(ctx context.Context) error {
|
||||
dhcpCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
addr, err := c.requestWithBackoff(dhcpCtx)
|
||||
addr, err := c.requestWithBackoff(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("DHCPv6 client failed: %w", err)
|
||||
}
|
||||
|
||||
c.addr = addr
|
||||
|
||||
c.initRebootFlag = false
|
||||
|
||||
c.storeAddr(addr)
|
||||
|
||||
// Set up two ticker to renew/rebind regularly
|
||||
t1Timeout := c.addr.PreferredLifetime / 2
|
||||
t2Timeout := (c.addr.ValidLifetime / 8) * 7
|
||||
log.Debug("[DHCPv6] timeouts", "timeout1", t1Timeout, "timeout2", t2Timeout)
|
||||
t1, t2 := time.NewTicker(t1Timeout), time.NewTicker(t2Timeout)
|
||||
t1Timeout, t2Timeout := getAddrTimeouts(addr)
|
||||
t1, t2 := time.NewTimer(t1Timeout), time.NewTimer(t2Timeout)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return c.killProcessing(t1, t2)
|
||||
case <-ctx.Done():
|
||||
c.close()
|
||||
return c.killProcessing(t1, t2)
|
||||
case <-t1.C:
|
||||
// renew is a unicast request of the IP renewal
|
||||
// A point on renew is: the library does not return the right message (NAK)
|
||||
@@ -184,49 +202,65 @@ func (c *DHCPv6Client) Start(ctx context.Context) error {
|
||||
// This way there's not much to do other than log and continue, as the renew error
|
||||
// may be an offline server, or may be an incorrect package match
|
||||
|
||||
addr, err := c.renew(dhcpCtx)
|
||||
addr, err := c.renew(ctx)
|
||||
if err == nil {
|
||||
c.addr = addr
|
||||
c.storeAddr(addr)
|
||||
log.Info("[DHCPv6] renew", "addr", addr.IPv6Addr.String())
|
||||
t1Timeout, t2Timeout = getAddrTimeouts(addr)
|
||||
t2.Reset(t2Timeout)
|
||||
} else {
|
||||
log.Error("[DHCPv6] renew failed", "err", err)
|
||||
}
|
||||
t1.Reset(t1Timeout)
|
||||
case <-t2.C:
|
||||
// rebind is just like a request, but forcing to provide a new IP address
|
||||
addr, err := c.request(dhcpCtx, true)
|
||||
addr, err := c.request(ctx, true)
|
||||
if err == nil {
|
||||
c.addr = addr
|
||||
c.storeAddr(addr)
|
||||
log.Info("[DHCPv6] rebind", "lease", addr)
|
||||
t1Timeout, t2Timeout = getAddrTimeouts(addr)
|
||||
} else {
|
||||
addr = c.loadAddr()
|
||||
log.Warn("[DHCPv6] ip may have changed", "ip", addr.IPv6Addr.String(), "err", err)
|
||||
c.initRebootFlag = false
|
||||
c.addr, err = c.requestWithBackoff(dhcpCtx)
|
||||
log.Error("[DHCPv6] rebind failed", "err", err)
|
||||
addr, backoffErr := c.requestWithBackoff(ctx)
|
||||
if backoffErr != nil {
|
||||
log.Error("[DHCPv6] failed to reacquire lease", "err", backoffErr)
|
||||
continue
|
||||
}
|
||||
c.storeAddr(addr)
|
||||
t1Timeout, t2Timeout = getAddrTimeouts(addr)
|
||||
}
|
||||
t1.Reset(t1Timeout)
|
||||
t2.Reset(t2Timeout)
|
||||
|
||||
case <-c.stopChan:
|
||||
// create new context for DHCP cleanup (independent)
|
||||
dhcpStopCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// IP address release.
|
||||
var err error
|
||||
if err = c.release(dhcpStopCtx); err != nil {
|
||||
log.Error("[DHCPv6] release failed", "err", err)
|
||||
} else {
|
||||
log.Info("[DHCPv6] released", "address", c.addr.String())
|
||||
}
|
||||
t1.Stop()
|
||||
t2.Stop()
|
||||
|
||||
close(c.releasedChan)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getAddrTimeouts(addr *dhcpv6.OptIAAddress) (time.Duration, time.Duration) {
|
||||
t1Timeout, t2Timeout := addr.PreferredLifetime/2, (addr.ValidLifetime/8)*7
|
||||
log.Debug("[DHCPv6] timeouts", "address", addr.IPv6Addr.String(), "T1", t1Timeout, "T2", t2Timeout)
|
||||
return t1Timeout, t2Timeout
|
||||
}
|
||||
|
||||
func (c *DHCPv6Client) killProcessing(t1, t2 *time.Timer) error {
|
||||
// create new context for DHCP cleanup (independent)
|
||||
dhcpStopCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// IP address release.
|
||||
var err error
|
||||
if c.loadAddr() != nil {
|
||||
if err = c.release(dhcpStopCtx); err != nil {
|
||||
log.Error("[DHCPv6] release failed", "err", err)
|
||||
} else {
|
||||
log.Info("[DHCPv6] released", "address", c.addr.String())
|
||||
}
|
||||
}
|
||||
t1.Stop()
|
||||
t2.Stop()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *DHCPv6Client) requestWithBackoff(ctx context.Context) (*dhcpv6.OptIAAddress, error) {
|
||||
backoff := backoff.Backoff{
|
||||
Factor: 2,
|
||||
@@ -235,33 +269,50 @@ func (c *DHCPv6Client) requestWithBackoff(ctx context.Context) (*dhcpv6.OptIAAdd
|
||||
Max: 1 * time.Minute,
|
||||
}
|
||||
|
||||
var err error
|
||||
var addr *dhcpv6.OptIAAddress
|
||||
var err error
|
||||
|
||||
log.Debug("[DHCPv6]", "attempts", c.backoffAttempts)
|
||||
|
||||
RequestLoop:
|
||||
for {
|
||||
log.Debug("[DHCPv6] trying to get a new IP", "attempt", backoff.Attempt()+1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("[DHCPv6] context error: %w", ctx.Err())
|
||||
default:
|
||||
log.Debug("[DHCPv6] trying to get a new IP", "attempt", backoff.Attempt()+1)
|
||||
|
||||
addr, err = c.request(ctx, false)
|
||||
addr, err = c.request(ctx, false)
|
||||
|
||||
if err != nil {
|
||||
dur := backoff.Duration()
|
||||
if c.backoffAttempts > 0 && backoff.Attempt() > float64(c.backoffAttempts)-1 {
|
||||
errMsg := fmt.Errorf("failed to get an IPv4 address after %d attempt(s), giving up, error: %s", c.backoffAttempts, err.Error())
|
||||
log.Error(fmt.Sprintf("[DHCPv6] %s", errMsg.Error()))
|
||||
c.errorChan <- errMsg
|
||||
return nil, fmt.Errorf("failed to get IPv6 address: %w", err)
|
||||
if err != nil {
|
||||
dur := backoff.Duration()
|
||||
if c.backoffAttempts > 0 && backoff.Attempt() > float64(c.backoffAttempts)-1 {
|
||||
errMsg := fmt.Errorf("failed to get an IPv6 address after %d attempt(s), giving up, error: %s", c.backoffAttempts, err.Error())
|
||||
log.Error(fmt.Sprintf("[DHCPv6] %s", errMsg.Error()))
|
||||
c.errorChan <- errMsg
|
||||
return nil, fmt.Errorf("failed to get IPv6 address: %w", err)
|
||||
}
|
||||
log.Error("[DHCPv6] request failed", "attempt", backoff.Attempt(), "err", err.Error(), "waiting", dur)
|
||||
t := time.NewTimer(dur)
|
||||
select {
|
||||
case <-t.C:
|
||||
t.Stop()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue RequestLoop
|
||||
}
|
||||
log.Error("[DHCPv6] request failed", "attempt", backoff.Attempt(), "err", err.Error(), "waiting", dur)
|
||||
time.Sleep(dur)
|
||||
continue
|
||||
backoff.Reset()
|
||||
break RequestLoop
|
||||
}
|
||||
backoff.Reset()
|
||||
break
|
||||
}
|
||||
|
||||
if c.ipChan != nil {
|
||||
log.Debug("[DHCPv6] using channel")
|
||||
c.ipChan <- addr.IPv6Addr.String()
|
||||
// Nothing closes ipChan, so never block on a consumer that already stopped.
|
||||
select {
|
||||
case c.ipChan <- addr.IPv6Addr.String():
|
||||
case <-c.stopChan:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
return addr, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package vip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -21,14 +22,13 @@ func TestDHCPv6StopReleasesManagerReferenceForParentInterface(t *testing.T) {
|
||||
}
|
||||
|
||||
client := &DHCPv6Client{
|
||||
iface: &net.Interface{Name: "vlan-child"},
|
||||
managerKey: "parent0",
|
||||
ipChan: make(chan string),
|
||||
stopChan: make(chan struct{}),
|
||||
releasedChan: make(chan struct{}),
|
||||
ic: shared,
|
||||
iface: &net.Interface{Name: "vlan-child"},
|
||||
managerKey: "parent0",
|
||||
ipChan: make(chan string),
|
||||
stopChan: make(chan struct{}),
|
||||
ic: shared,
|
||||
addr: &dhcpv6.OptIAAddress{},
|
||||
}
|
||||
close(client.releasedChan)
|
||||
|
||||
client.Stop()
|
||||
|
||||
@@ -37,6 +37,38 @@ func TestDHCPv6StopReleasesManagerReferenceForParentInterface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHCPv6ClientManagerSharesOneClientPerParentInterface(t *testing.T) {
|
||||
references := &atomic.Int32{}
|
||||
references.Store(1)
|
||||
shared := &DHCPv6InternalClient{references: references}
|
||||
manager := &DHCPv6ClientManager{
|
||||
clients: map[string]*DHCPv6InternalClient{"parent0": shared},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 64 {
|
||||
wg.Go(func() {
|
||||
client, err := manager.Add("parent0")
|
||||
if err != nil {
|
||||
t.Errorf("Add() error = %v", err)
|
||||
return
|
||||
}
|
||||
if client != shared {
|
||||
t.Errorf("Add() client = %p, want the shared client %p", client, shared)
|
||||
}
|
||||
manager.Delete("parent0")
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := manager.Get("parent0"); got != shared {
|
||||
t.Fatalf("shared client = %v, want it retained while still referenced", got)
|
||||
}
|
||||
if got := references.Load(); got != 1 {
|
||||
t.Fatalf("manager reference count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAddressRejectsIANAWithoutAddresses(t *testing.T) {
|
||||
// DEFECT: getAddress indexes the first IAADDR without checking whether the IANA contains one, so a malformed/expired reply panics (pkg/vip/dhcpv6.go:392).
|
||||
defer func() {
|
||||
|
||||
110
pkg/vip/util.go
110
pkg/vip/util.go
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
var errDefaultInterfaceSubscriptionClosed = errors.New("default interface subscription closed")
|
||||
|
||||
// getHostName return the hostname from the fqdn
|
||||
func getHostName(dnsName string) string {
|
||||
if dnsName == "" {
|
||||
@@ -75,26 +78,123 @@ func getDefaultRoute(family int) (*net.Interface, error) {
|
||||
return nil, errors.New("default route not found")
|
||||
}
|
||||
|
||||
// MonitorDefaultInterface monitor the default interface and catch the event of the default route
|
||||
// MonitorDefaultInterface monitors the default interface for route removal or link loss.
|
||||
func MonitorDefaultInterface(ctx context.Context, defaultIF *net.Interface) error {
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
return monitorDefaultInterfaceWithRetry(ctx, defaultIF, subscribeDefaultInterface, GetDefaultGatewayInterface, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func monitorDefaultInterfaceWithRetry(ctx context.Context, defaultIF *net.Interface,
|
||||
subscribe func(context.Context) (chan netlink.RouteUpdate, chan netlink.LinkUpdate, error),
|
||||
lookup func() (*net.Interface, error), retryDelay time.Duration) error {
|
||||
for {
|
||||
monitorCtx, cancel := context.WithCancel(ctx)
|
||||
routeCh, linkCh, err := subscribe(monitorCtx)
|
||||
if err == nil {
|
||||
err = monitorDefaultInterface(monitorCtx, defaultIF, routeCh, linkCh)
|
||||
}
|
||||
cancel()
|
||||
drainDefaultInterfaceSubscriptions(routeCh, linkCh)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, errDefaultInterfaceSubscriptionClosed) {
|
||||
log.Warn("default interface subscription failed, retrying", "err", err)
|
||||
} else if err == nil {
|
||||
return nil
|
||||
}
|
||||
if refreshed, lookupErr := lookup(); lookupErr == nil {
|
||||
defaultIF = refreshed
|
||||
} else {
|
||||
log.Warn("failed to refresh default interface while resubscribing", "err", lookupErr)
|
||||
}
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeDefaultInterface(ctx context.Context) (chan netlink.RouteUpdate, chan netlink.LinkUpdate, error) {
|
||||
const subscriptionBuffer = 64
|
||||
routeCh := make(chan netlink.RouteUpdate, subscriptionBuffer)
|
||||
if err := netlink.RouteSubscribe(routeCh, ctx.Done()); err != nil {
|
||||
return fmt.Errorf("subscribe route failed, error: %w", err)
|
||||
return nil, nil, fmt.Errorf("subscribe route failed, error: %w", err)
|
||||
}
|
||||
linkCh := make(chan netlink.LinkUpdate, subscriptionBuffer)
|
||||
if err := netlink.LinkSubscribe(linkCh, ctx.Done()); err != nil {
|
||||
return routeCh, nil, fmt.Errorf("subscribe link failed, error: %w", err)
|
||||
}
|
||||
|
||||
return routeCh, linkCh, nil
|
||||
}
|
||||
|
||||
func monitorDefaultInterface(ctx context.Context, defaultIF *net.Interface, routeCh <-chan netlink.RouteUpdate, linkCh <-chan netlink.LinkUpdate) error {
|
||||
for {
|
||||
select {
|
||||
case r := <-routeCh:
|
||||
case r, ok := <-routeCh:
|
||||
if !ok {
|
||||
return subscriptionClosed(ctx, "route")
|
||||
}
|
||||
log.Debug(fmt.Sprintf("type: %d, route: %+v", r.Type, r.Route))
|
||||
if r.Type == syscall.RTM_DELROUTE && (r.Dst == nil || r.Dst.String() == "0.0.0.0/0") && r.LinkIndex == defaultIF.Index {
|
||||
if r.Type == syscall.RTM_DELROUTE && isDefaultRoute(r.Dst) && r.LinkIndex == defaultIF.Index {
|
||||
return fmt.Errorf("default route deleted and the default interface may be invalid")
|
||||
}
|
||||
case update, ok := <-linkCh:
|
||||
if !ok {
|
||||
return subscriptionClosed(ctx, "link")
|
||||
}
|
||||
if update.Link == nil {
|
||||
continue
|
||||
}
|
||||
attrs := update.Attrs()
|
||||
if attrs != nil && attrs.Index == defaultIF.Index && attrs.Flags&net.FlagUp == 0 {
|
||||
return fmt.Errorf("default interface %q is down", defaultIF.Name)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionClosed(ctx context.Context, subscription string) error {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %s subscription closed", errDefaultInterfaceSubscriptionClosed, subscription)
|
||||
}
|
||||
|
||||
// isDefaultRoute accepts both families, matching the selection made by
|
||||
// GetDefaultGatewayInterface.
|
||||
func isDefaultRoute(dst *net.IPNet) bool {
|
||||
if dst == nil {
|
||||
return true
|
||||
}
|
||||
return dst.String() == "0.0.0.0/0" || dst.String() == "::/0"
|
||||
}
|
||||
|
||||
func drainDefaultInterfaceSubscriptions(routeCh <-chan netlink.RouteUpdate, linkCh <-chan netlink.LinkUpdate) {
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
for routeCh != nil || linkCh != nil {
|
||||
select {
|
||||
case _, ok := <-routeCh:
|
||||
if !ok {
|
||||
routeCh = nil
|
||||
}
|
||||
case _, ok := <-linkCh:
|
||||
if !ok {
|
||||
linkCh = nil
|
||||
}
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateMac() (mac string) {
|
||||
buf := make([]byte, 3)
|
||||
_, err := rand.Read(buf)
|
||||
|
||||
293
pkg/vip/util_linux_test.go
Normal file
293
pkg/vip/util_linux_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
//go:build linux
|
||||
|
||||
package vip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
func TestMonitorDefaultInterfaceDetectsDefaultRouteDeletionPerFamily(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cidr string
|
||||
}{
|
||||
{name: "IPv4", cidr: "0.0.0.0/0"},
|
||||
{name: "IPv6", cidr: "::/0"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
defaultIF := &net.Interface{Index: 7, Name: "test0"}
|
||||
_, defaultRoute, err := net.ParseCIDR(test.cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCIDR() error = %v", err)
|
||||
}
|
||||
routeCh := make(chan netlink.RouteUpdate, 1)
|
||||
routeCh <- netlink.RouteUpdate{
|
||||
Type: syscall.RTM_DELROUTE,
|
||||
Route: netlink.Route{Dst: defaultRoute, LinkIndex: defaultIF.Index},
|
||||
}
|
||||
linkCh := make(chan netlink.LinkUpdate)
|
||||
|
||||
err = monitorDefaultInterfaceForTest(t, context.Background(), defaultIF, routeCh, linkCh)
|
||||
if err == nil || !strings.Contains(err.Error(), "default route deleted") {
|
||||
t.Fatalf("monitor error = %v, want a default route deletion error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceReturnsErrorWhenLinkGoesDown(t *testing.T) {
|
||||
defaultIF := &net.Interface{Index: 7, Name: "test0"}
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
linkCh := make(chan netlink.LinkUpdate, 1)
|
||||
linkCh <- netlink.LinkUpdate{
|
||||
Link: &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Index: defaultIF.Index}},
|
||||
}
|
||||
|
||||
err := monitorDefaultInterfaceForTest(t, context.Background(), defaultIF, routeCh, linkCh)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the default interface goes down")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "default interface \"test0\" is down") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceHandlesClosedSubscriptions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
closeRoute bool
|
||||
cancel bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "closed route subscription", closeRoute: true, wantErr: "route subscription closed"},
|
||||
{name: "closed link subscription", wantErr: "link subscription closed"},
|
||||
{name: "context cancellation with closed route subscription", closeRoute: true, cancel: true},
|
||||
{name: "context cancellation with closed link subscription", cancel: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
linkCh := make(chan netlink.LinkUpdate)
|
||||
if test.closeRoute {
|
||||
close(routeCh)
|
||||
} else {
|
||||
close(linkCh)
|
||||
}
|
||||
if test.cancel {
|
||||
cancel()
|
||||
}
|
||||
|
||||
err := monitorDefaultInterfaceForTest(t, ctx, &net.Interface{}, routeCh, linkCh)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("monitor error = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("monitor error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func monitorDefaultInterfaceForTest(t *testing.T, ctx context.Context, defaultIF *net.Interface,
|
||||
routeCh <-chan netlink.RouteUpdate, linkCh <-chan netlink.LinkUpdate) error {
|
||||
t.Helper()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- monitorDefaultInterface(ctx, defaultIF, routeCh, linkCh)
|
||||
}()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("default interface monitor did not return")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceHandlesClosedSubscriptionsAfterCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
linkCh := make(chan netlink.LinkUpdate)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- monitorDefaultInterface(ctx, &net.Interface{}, routeCh, linkCh)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
close(routeCh)
|
||||
close(linkCh)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("monitor error = %v, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceRetriesClosedSubscription(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
secondSubscribed := make(chan struct{})
|
||||
attempts := 0
|
||||
subscribe := func(ctx context.Context) (chan netlink.RouteUpdate, chan netlink.LinkUpdate, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attempts++
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
linkCh := make(chan netlink.LinkUpdate)
|
||||
if attempts == 1 {
|
||||
close(routeCh)
|
||||
close(linkCh)
|
||||
return routeCh, linkCh, nil
|
||||
}
|
||||
close(secondSubscribed)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(routeCh)
|
||||
close(linkCh)
|
||||
}()
|
||||
return routeCh, linkCh, nil
|
||||
}
|
||||
lookup := func() (*net.Interface, error) {
|
||||
return &net.Interface{Index: 2, Name: "refreshed"}, nil
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- monitorDefaultInterfaceWithRetry(ctx, &net.Interface{Index: 1, Name: "original"}, subscribe, lookup, time.Millisecond)
|
||||
}()
|
||||
select {
|
||||
case <-secondSubscribed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor did not resubscribe after channel closure")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("monitor returned an error after cancellation: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("resubscribed monitor did not stop after cancellation")
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("subscription attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceIgnoresNilLink(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
routeCh := make(chan netlink.RouteUpdate)
|
||||
linkCh := make(chan netlink.LinkUpdate)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- monitorDefaultInterface(ctx, &net.Interface{}, routeCh, linkCh)
|
||||
}()
|
||||
|
||||
linkCh <- netlink.LinkUpdate{}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("monitor error = %v, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor did not stop after nil link update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainDefaultInterfaceSubscriptionsIsBounded(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
drainDefaultInterfaceSubscriptions(make(chan netlink.RouteUpdate), make(chan netlink.LinkUpdate))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscription drain did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDefaultInterfaceReturnsErrorWhenTestLinkIsSetDown(t *testing.T) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
originalNS, err := netns.Get()
|
||||
if err != nil {
|
||||
t.Fatalf("getting current network namespace: %v", err)
|
||||
}
|
||||
defer originalNS.Close()
|
||||
|
||||
testNS, 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 testNS.Close()
|
||||
defer func() {
|
||||
if err := netns.Set(originalNS); err != nil {
|
||||
t.Errorf("restoring network namespace: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kv-monitor0"}}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
t.Fatalf("creating test interface: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
t.Errorf("deleting test interface: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
t.Fatalf("bringing test interface up: %v", err)
|
||||
}
|
||||
|
||||
defaultIF, err := net.InterfaceByName(link.Attrs().Name)
|
||||
if err != nil {
|
||||
t.Fatalf("getting test interface: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
routeCh, linkCh, err := subscribeDefaultInterface(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribing to link updates: %v", err)
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- monitorDefaultInterface(ctx, defaultIF, routeCh, linkCh)
|
||||
}()
|
||||
|
||||
if err := netlink.LinkSetDown(link); err != nil {
|
||||
t.Fatalf("bringing test interface down: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "default interface \"kv-monitor0\" is down") {
|
||||
t.Fatalf("monitor error = %v, want default-interface-down error", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor did not report the interface going down")
|
||||
}
|
||||
}
|
||||
39
pkg/wireguard/service_id.go
Normal file
39
pkg/wireguard/service_id.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
const maxServicePortIDLength = 50
|
||||
|
||||
// ServicePortIDs returns the protocol-qualified nftables identifier and the
|
||||
// prior port-only identifier that must be removed during migration.
|
||||
//
|
||||
// Sanitisation maps '-' onto the '_' separator, so "a-b/c" and "a/b-c" would
|
||||
// otherwise share a chain; a hash of the raw name keeps them distinct.
|
||||
func ServicePortIDs(namespace, name string, port v1.ServicePort) (string, string) {
|
||||
rawServiceID := fmt.Sprintf("%s_%s", namespace, name)
|
||||
serviceID := utils.SanitizeServiceID(rawServiceID)
|
||||
legacyID := fmt.Sprintf("%s_p%d", serviceID, port.Port)
|
||||
protocol := port.Protocol
|
||||
if protocol == "" {
|
||||
protocol = v1.ProtocolTCP
|
||||
}
|
||||
suffix := fmt.Sprintf("_p%d_%s", port.Port, strings.ToLower(string(protocol)))
|
||||
maxBase := maxServicePortIDLength - len(suffix)
|
||||
if serviceID != rawServiceID || len(serviceID) > maxBase {
|
||||
sum := sha256.Sum256([]byte(rawServiceID))
|
||||
hash := fmt.Sprintf("_%x", sum[:8])
|
||||
maxPrefix := maxBase - len(hash)
|
||||
if len(serviceID) > maxPrefix {
|
||||
serviceID = serviceID[:maxPrefix]
|
||||
}
|
||||
serviceID += hash
|
||||
}
|
||||
return serviceID + suffix, legacyID
|
||||
}
|
||||
33
pkg/wireguard/service_id_test.go
Normal file
33
pkg/wireguard/service_id_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestServicePortIDsKeepProtocolAndLongNamesDistinct(t *testing.T) {
|
||||
udp := v1.ServicePort{Port: 53, Protocol: v1.ProtocolUDP}
|
||||
udpID, legacyID := ServicePortIDs("default", "dns", udp)
|
||||
if udpID != "default_dns_p53_udp" || legacyID != "default_dns_p53" {
|
||||
t.Fatalf("ServicePortIDs() = %q, %q", udpID, legacyID)
|
||||
}
|
||||
tcpID, _ := ServicePortIDs("default", "dns", v1.ServicePort{Port: 53, Protocol: v1.ProtocolTCP})
|
||||
if tcpID == udpID {
|
||||
t.Fatal("TCP and UDP Services sharing a port received the same rule ID")
|
||||
}
|
||||
|
||||
longPrefix := strings.Repeat("a", 63)
|
||||
first, _ := ServicePortIDs(longPrefix, "first", udp)
|
||||
second, _ := ServicePortIDs(longPrefix, "second", udp)
|
||||
if len(first) > maxServicePortIDLength || first == second {
|
||||
t.Fatalf("long Service IDs = %q, %q", first, second)
|
||||
}
|
||||
|
||||
hyphenatedNamespace, _ := ServicePortIDs("a-b", "c", udp)
|
||||
hyphenatedName, _ := ServicePortIDs("a", "b-c", udp)
|
||||
if hyphenatedNamespace == hyphenatedName {
|
||||
t.Fatalf("distinct Services received the same rule ID %q", hyphenatedNamespace)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
86
pkg/wireguard/tunnel_manager_test.go
Normal file
86
pkg/wireguard/tunnel_manager_test.go
Normal 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
|
||||
}
|
||||
@@ -1288,7 +1288,8 @@ func killLeader(leaderName string) {
|
||||
|
||||
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Eventually(session, "5s").Should(gexec.Exit(0))
|
||||
// Every parallel kind cluster shares one Docker daemon, so the kill can be acknowledged slowly.
|
||||
Eventually(session, "30s").Should(gexec.Exit(0))
|
||||
}
|
||||
|
||||
func findLeader(leaderIPAddr string, clusterName string) string {
|
||||
|
||||
Reference in New Issue
Block a user