Compare commits

...

24 Commits

Author SHA1 Message Date
Marcel Fest
711adae03e fix(manager): start the shutdown watcher before slow startup calls
A signal arriving during address recovery was only observed once the
call returned on its own, delaying shutdown by the length of the API
round trip. Start the watcher first, and make Kill non-blocking so a
second signal cannot deadlock against the first.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 21:00:31 +02:00
Marcel Fest
acd87f465d fix(vip): synchronize shared datapath state
Serialize the interface link cache, route tracker and address configuration so
concurrent Services cannot corrupt shared state or deadlock on nested address
locks.

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

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 17:20:52 +02:00
Marcel Fest
44a67bc901 fix: regression on preserveOnLeadershipLoss
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:44:08 +02:00
Marcel Fest
7df2e5dd46 test(e2e): give docker kill more time under parallel load
The ARP suite runs four kind clusters against one Docker daemon, so
acknowledging a leader kill regularly exceeded the 5s budget and failed
the IPv6 failover specs before any assertion ran.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:27:13 +02:00
Marcel Fest
ba8ffde3c8 fix(instance): roll back partially created instances
Instance creation added addresses, VLAN or macvlan links and DHCP
clients incrementally, so a failure part way through left the node
holding state nobody owned. Initialization now unwinds what it created,
and link cleanup only deletes attachments this instance created that no
remaining instance still uses.

Namespace-dependent tests now skip unless KUBE_VIP_REQUIRE_NETNS is set,
which CI sets on the privileged job so lost capabilities turn it red
instead of silently skipping.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:27:12 +02:00
Marcel Fest
65a6a6f8f2 fix(arp): guard manager state behind a single mutex
Instances were kept in a sync.Map with a per-instance mutex for the
refcount, so lookup and refcount update were not atomic: concurrent
Insert and Remove could resurrect a deleted instance or drop a live one.
Hold one manager mutex across both, and buffer link subscriptions so a
netlink sender is never parked on an unread channel during shutdown.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:27:11 +02:00
Marcel Fest
94ff8495f4 feat(wireguard): qualify service tunnel IDs by protocol
Sanitisation maps '-' onto the '_' separator, so "a-b/c" and "a/b-c"
shared one nftables chain, and TCP and UDP on the same port collided.
ServicePortIDs appends the protocol and, when sanitisation changed the
name or the ID grew too long, a hash of the raw name. It also returns
the previous port-only ID so existing chains can be migrated.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:27:10 +02:00
Marcel Fest
52021d9232 fix(kubevip): reject out-of-range routing protocol values
Netlink carries the address and route protocol in a single byte, so a
configured value above 255 was silently truncated on the wire and never
matched again on readback. Reject it during config validation instead.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:27:08 +02:00
Marcel Fest
776f0b18fa fix: dhcp panic and leak potential
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 15:26:45 +02:00
Marcel Fest
86ba1d09af fix(vip): synchronize shared datapath state
Serialize the interface link cache, route tracker and address configuration so
concurrent Services cannot corrupt shared state or deadlock on nested address
locks.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 14:01:57 +02:00
Marcel Fest
6512709032 fix: flip flop on ordering when reading and writing the annotation
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 13:56:04 +02:00
Marcel Fest
1196748efc feat(lease): record VIP ownership in lease annotations
Encode the instance name, address protocol and an ordered VIP list on the
Kubernetes lease so a restarting instance can tell which tagged addresses it
still owns. Only the current holder publishes the annotation.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
Signed-off-by: Cellebyte <marcel.fest@live.de>
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
2026-09-15 13:56:02 +02:00
dependabot[bot]
d557211b55 build(deps): bump golang.org/x/sys from 0.47.0 to 0.48.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/sys/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 13:38:41 +02:00
dependabot[bot]
e4eb5e8e7f build(deps): bump github.com/onsi/ginkgo/v2 from 2.32.1 to 2.32.2
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.32.1 to 2.32.2.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.32.1...v2.32.2)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-version: 2.32.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 13:09:08 +02:00
dependabot[bot]
a7b068f9d2 build(deps): bump golang.org/x/sync from 0.22.0 to 0.23.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/sync/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 13:08:26 +02:00
dependabot[bot]
a6bf5280d4 build(deps): bump anchore/sbom-action from 0.24.0 to 0.24.2
Bumps [anchore/sbom-action](https://github.com/anchore/sbom-action) from 0.24.0 to 0.24.2.
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/sbom-action/compare/v0.24.0...v0.24.2)

---
updated-dependencies:
- dependency-name: anchore/sbom-action
  dependency-version: 0.24.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-13 22:24:58 +02:00
Cellebyte
61a52e8f2a fix: function calls
Signed-off-by: Cellebyte <marcel.fest@live.de>
2026-09-13 22:02:01 +02:00
Cellebyte
7e2dd0d262 fix: add manager interface at the right place
Signed-off-by: Cellebyte <marcel.fest@live.de>
2026-09-13 22:02:01 +02:00
Cellebyte
7a085cb3ce fix(bgp-cp): mem-leak on stacking defers and never exit
Signed-off-by: Cellebyte <marcel.fest@live.de>
2026-09-13 22:02:01 +02:00
Marcel Fest
1f5c135fd1 use ipv6 in error message 2026-09-13 22:01:14 +02:00
Patryk Strusiewicz-Surmacki
b4771c5319 Fixed DHCP concurrency exit issues
Signed-off-by: Patryk Strusiewicz-Surmacki <patryk.pawel.strusiewicz-surmacki@external.telekom.de>
2026-09-13 22:01:14 +02:00
Marcel Fest
3771ccee29 Add suggestion for IPv4/IPv6 swap 2026-09-13 21:47:08 +02:00
81 changed files with 9961 additions and 2275 deletions

13
.github/suggestion-comment.md vendored Normal file
View 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?

View File

@@ -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

View File

@@ -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

View File

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

12
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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)
}
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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 "" }

View File

@@ -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)

View 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
}

View 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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,19 @@ package endpoints
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/cluster"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/metrics"
"github.com/kube-vip/kube-vip/pkg/route"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/prometheus/client_golang/prometheus/testutil"
v1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
)
@@ -47,6 +46,7 @@ type fakeWorker struct {
endpoints []string
clearCalled bool
processCalled bool
processHook func()
}
type annotationUpdate struct {
@@ -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:
}
}

View File

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

View File

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

View File

@@ -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, &notFound) {
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, &notFound) {
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)

View 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")
}
}

View 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, &notFound) {
t.Fatalf("owned VLAN remains after cleanup: %v", err)
}
})
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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 {

View 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
}

View 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")
}
}

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")
}
}

View File

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

View File

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

View File

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

View File

@@ -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)
}

View 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)
}
}

View 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}}
}

View File

@@ -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
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"testing"
"time"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
@@ -46,13 +47,13 @@ func TestAddOrModifyStopsTrackedServiceWhenTypeChanges(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
ServiceInstances: []*instance.Instance{{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)

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import (
log "log/slog"
"github.com/google/go-cmp/cmp"
"github.com/vishvananda/netlink"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -25,8 +24,6 @@ import (
"github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/nftables"
"github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/upnp"
"github.com/kube-vip/kube-vip/pkg/utils"
@@ -45,31 +42,32 @@ const (
)
func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, wg *sync.WaitGroup, usesLeaderElection bool) error {
return p.syncServicesWithContext(ctx.Ctx, ctx, svc, wg, usesLeaderElection)
}
func (p *Processor) syncServicesWithContext(operationCtx context.Context, svcCtx *servicecontext.Context,
svc *v1.Service, wg *sync.WaitGroup, usesLeaderElection bool) error {
log.Debug("[STARTING] Service Sync", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
// Iterate through the synchronising services
action, instance := p.getServiceInstanceAction(svc)
action := p.getServiceInstanceAction(svc)
switch action {
case ActionDelete:
log.Debug("[service] delete", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
if err := p.deleteService(ctx.Ctx, svc.UID); err != nil {
if err := p.deleteService(operationCtx, svc.UID); err != nil {
return fmt.Errorf("error deleting service %s/%s: %w", svc.Namespace, svc.Name, err)
}
case ActionAdd:
log.Debug("[service] add", "namespace", svc.Namespace, "name", svc.Name, "uid", svc.UID)
if instance != nil {
instance.AddCalled = true
}
if !usesLeaderElection {
select {
case <-ctx.Ctx.Done():
releaseReadiness, ready := svcCtx.WaitForReadiness()
if !ready {
return nil
case <-ctx.GetEndpointsReady():
}
defer releaseReadiness()
}
if err := p.addService(ctx.Ctx, instance, svc, wg); err != nil {
if err := p.addService(operationCtx, svc, wg); err != nil {
return fmt.Errorf("error adding service %s/%s: %w", svc.Namespace, svc.Name, err)
}
@@ -80,7 +78,7 @@ func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, w
// LB IP, the initial addService call may have missed the SNAT configuration because
// ActiveEndpoint was not yet present. Re-run it here.
if svc.Annotations[kubevip.Egress] == "true" && svc.Annotations[kubevip.ActiveEndpoint] != "" {
if err := p.updateEgressConfiguration(ctx.Ctx, svc); err != nil {
if err := p.updateEgressConfiguration(operationCtx, svc); err != nil {
log.Warn("[service] egress reconfigure on ActionNone", "service", svc.Name, "namespace", svc.Namespace, "err", err)
}
}
@@ -89,65 +87,64 @@ func (p *Processor) SyncServices(ctx *servicecontext.Context, svc *v1.Service, w
return nil
}
func (p *Processor) getServiceInstanceAction(svc *v1.Service) (ServiceInstanceAction, *instance.Instance) {
func (p *Processor) getServiceInstanceAction(svc *v1.Service) ServiceInstanceAction {
unlockService := p.lockService(svc.UID)
defer unlockService()
// protect against multiple calls
// get the annotations or legacy values from manual configuration
addresses, hostnames := instance.FetchServiceAddresses(svc)
// get the status information of the LB Service
statusAddresses, _ := instance.FetchLoadBalancerIngress(svc)
p.mutex.Lock()
defer p.mutex.Unlock()
for _, instance := range p.ServiceInstances {
if instance != nil && instance.ServiceSnapshot.UID == svc.UID {
if !instance.AddCalled {
return ActionAdd, instance
}
for _, address := range addresses {
// handle the case where the service instance needs to be deleted
if instance.IsDHCPv4 {
if address != "0.0.0.0" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, instance.DHCPInterfaceIPv4) {
return ActionDelete, instance
}
} else {
if address == "0.0.0.0" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete, instance
}
}
if instance.IsDHCPv6 {
if address != "::" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, instance.DHCPInterfaceIPv6) {
return ActionDelete, instance
}
} else {
if address == "::" {
return ActionDelete, instance
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete, instance
}
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !comparePortsAndPortStatuses(svc) {
return ActionDelete, instance
}
}
// If we reach here, it means the service instance matches the service UID and is not a DHCP service, so we can return "no action"
return ActionNone, instance
inst := p.findServiceInstance(svc)
if inst != nil {
if !inst.AddCalled {
return ActionAdd
}
for _, address := range addresses {
// handle the case where the service instance needs to be deleted
if inst.IsDHCPv4 {
if address != "0.0.0.0" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, inst.DHCPInterfaceIPv4) {
return ActionDelete
}
} else {
if address == "0.0.0.0" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete
}
}
if inst.IsDHCPv6 {
if address != "::" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, inst.DHCPInterfaceIPv6) {
return ActionDelete
}
} else {
if address == "::" {
return ActionDelete
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !slices.Contains(statusAddresses, address) {
return ActionDelete
}
}
if len(svc.Status.LoadBalancer.Ingress) > 0 && !comparePortsAndPortStatuses(svc) {
return ActionDelete
}
}
// If we reach here, it means the service instance matches the service UID and is not a DHCP service, so we can return "no action"
return ActionNone
}
if len(addresses) > 0 || len(hostnames) > 0 {
log.Debug("no matching service instance found", "service", svc.Name, "namespace", svc.Namespace, "uid", svc.UID, "addresses", addresses, "hostnames", hostnames)
return ActionAdd, nil // If no matching instance is found, we need to add a new service instance
return ActionAdd // If no matching instance is found, we need to add a new service instance
}
return ActionNone, nil
return ActionNone
}
func comparePortsAndPortStatuses(svc *v1.Service) bool {
@@ -166,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)

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,10 @@ package services
import (
"context"
"errors"
"fmt"
"sync"
"time"
log "log/slog"
@@ -15,11 +17,119 @@ import (
"github.com/prometheus/client_golang/prometheus"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/tools/cache"
watchtools "k8s.io/client-go/tools/watch"
"k8s.io/client-go/util/workqueue"
)
const concurrentServiceEventWorkers = 4
const serviceAddressRetryDelay = time.Second
type serviceEventTask struct {
uid types.UID
run func() time.Duration
}
type serviceEventQueue struct {
ctx context.Context
queue workqueue.TypedDelayingInterface[types.NamespacedName]
mutex sync.Mutex
tasks map[types.NamespacedName][]*serviceEventTask
wg sync.WaitGroup
}
func newServiceEventQueue(ctx context.Context, workers int) *serviceEventQueue {
q := &serviceEventQueue{
ctx: ctx,
queue: workqueue.NewTypedDelayingQueue[types.NamespacedName](),
tasks: make(map[types.NamespacedName][]*serviceEventTask),
}
for range workers {
q.wg.Go(q.run)
}
return q
}
func (q *serviceEventQueue) Add(key types.NamespacedName, uid types.UID, run func() time.Duration) {
q.mutex.Lock()
defer q.mutex.Unlock()
if q.ctx.Err() != nil || q.queue.ShuttingDown() {
return
}
tasks := q.tasks[key]
task := &serviceEventTask{uid: uid, run: run}
if len(tasks) != 0 && tasks[len(tasks)-1].uid == uid {
tasks[len(tasks)-1] = task
} else {
tasks = append(tasks, task)
}
q.tasks[key] = tasks
q.queue.Add(key)
}
func (q *serviceEventQueue) run() {
for {
key, shutdown := q.queue.Get()
if shutdown {
return
}
q.runNext(key)
}
}
func (q *serviceEventQueue) runNext(key types.NamespacedName) {
defer q.queue.Done(key)
for {
task := q.nextTask(key)
if task == nil {
return
}
if q.ctx.Err() == nil {
if retryAfter := task.run(); retryAfter > 0 {
q.retry(key, task, retryAfter)
return
}
}
}
}
func (q *serviceEventQueue) retry(key types.NamespacedName, task *serviceEventTask, retryAfter time.Duration) {
q.mutex.Lock()
defer q.mutex.Unlock()
if q.ctx.Err() != nil || q.queue.ShuttingDown() || len(q.tasks[key]) != 0 {
return
}
q.tasks[key] = []*serviceEventTask{task}
q.queue.AddAfter(key, retryAfter)
}
func (q *serviceEventQueue) nextTask(key types.NamespacedName) *serviceEventTask {
q.mutex.Lock()
defer q.mutex.Unlock()
tasks := q.tasks[key]
if len(tasks) == 0 {
delete(q.tasks, key)
return nil
}
task := tasks[0]
if len(tasks) == 1 {
delete(q.tasks, key)
} else {
q.tasks[key] = tasks[1:]
}
return task
}
func (q *serviceEventQueue) Wait() {
q.queue.ShutDown()
q.wg.Wait()
q.mutex.Lock()
defer q.mutex.Unlock()
clear(q.tasks)
}
// This function handles the watching of a services endpoints and updates a load balancers endpoint configurations accordingly
func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback, forcedOnly bool) error {
// first start port mirroring if enabled
@@ -41,6 +151,9 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
} else {
log.Info("(svcs) starting services watcher", "namespace", p.config.ServiceNamespace)
}
if err := p.RecoverAddresses(ctx); err != nil {
log.Warn("skipping kube-vip address recovery", "err", err)
}
// Use a restartable watcher, as this should help in the event of etcd or timeout issues
rw, err := watchtools.NewRetryWatcherWithContext(ctx, "1", &cache.ListWatch{
@@ -60,15 +173,16 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
}
var wg sync.WaitGroup
watcherCtx, cancelWatcher := context.WithCancelCause(ctx)
eventQueue := newServiceEventQueue(watcherCtx, concurrentServiceEventWorkers)
defer func() {
if d != nil {
d.Stop()
}
rw.Stop()
eventQueue.Wait()
wg.Wait()
}()
watcherCtx, cancelWatcher := context.WithCancelCause(ctx)
defer cancelWatcher(nil)
wg.Go(func() {
@@ -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")

View File

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

View File

@@ -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
}

View File

@@ -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

View 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)
}
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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() {

View File

@@ -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
View 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")
}
}

View 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
}

View 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)
}
}

View File

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

View File

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

View File

@@ -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 {