Skip to content

Commit 39d4e87

Browse files
oilbeaterclaude
authored andcommitted
fix(controller): prevent NPE in isOvnSubnet when subnet is nil (kubeovn#6312)
When an IP CR references a deleted subnet, handleUpdateIP calls isOvnSubnet with a nil subnet pointer, causing a panic that puts the controller into CrashLoopBackOff. Add a nil check so that isOvnSubnet safely returns false for nil input. Fixes kubeovn#6309 Signed-off-by: Mengxin Liu <liumengxinfly@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 3a28ee1) (cherry picked from commit be347ca)
1 parent 5967f3a commit 39d4e87

File tree

5 files changed

+128
-14
lines changed

5 files changed

+128
-14
lines changed

pkg/controller/controller_test.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ import (
3434
kubeovnfake "github.com/kubeovn/kube-ovn/pkg/client/clientset/versioned/fake"
3535
kubeovninformerfactory "github.com/kubeovn/kube-ovn/pkg/client/informers/externalversions"
3636
kubeovninformer "github.com/kubeovn/kube-ovn/pkg/client/informers/externalversions/kubeovn/v1"
37+
ovnipam "github.com/kubeovn/kube-ovn/pkg/ipam"
3738
"github.com/kubeovn/kube-ovn/pkg/util"
3839
)
3940

4041
type fakeControllerInformers struct {
4142
vpcInformer kubeovninformer.VpcInformer
4243
vpcNatGwInformer kubeovninformer.VpcNatGatewayInformer
4344
subnetInformer kubeovninformer.SubnetInformer
45+
ipInformer kubeovninformer.IPInformer
4446
serviceInformer coreinformers.ServiceInformer
4547
namespaceInformer coreinformers.NamespaceInformer
4648
podInformer coreinformers.PodInformer
@@ -57,6 +59,7 @@ func alwaysReady() bool { return true }
5759
// FakeControllerOptions holds optional parameters for creating a fake controller
5860
type FakeControllerOptions struct {
5961
Subnets []*kubeovnv1.Subnet
62+
IPs []*kubeovnv1.IP
6063
NetworkAttachments []*nadv1.NetworkAttachmentDefinition
6164
Pods []*corev1.Pod
6265
Namespaces []*corev1.Namespace
@@ -112,6 +115,13 @@ func newFakeControllerWithOptions(t *testing.T, opts *FakeControllerOptions) (*f
112115
return nil, err
113116
}
114117
}
118+
for _, ip := range opts.IPs {
119+
_, err := kubeovnClient.KubeovnV1().IPs().Create(
120+
context.Background(), ip, metav1.CreateOptions{})
121+
if err != nil {
122+
return nil, err
123+
}
124+
}
115125

116126
// Create informer factories
117127
kubeInformerFactory := informers.NewSharedInformerFactory(kubeClient, 0)
@@ -125,12 +135,14 @@ func newFakeControllerWithOptions(t *testing.T, opts *FakeControllerOptions) (*f
125135
kubeovnInformerFactory := kubeovninformerfactory.NewSharedInformerFactory(kubeovnClient, 0)
126136
vpcInformer := kubeovnInformerFactory.Kubeovn().V1().Vpcs()
127137
subnetInformer := kubeovnInformerFactory.Kubeovn().V1().Subnets()
138+
ipInformer := kubeovnInformerFactory.Kubeovn().V1().IPs()
128139
vpcNatGwInformer := kubeovnInformerFactory.Kubeovn().V1().VpcNatGateways()
129140

130141
fakeInformers := &fakeControllerInformers{
131142
vpcInformer: vpcInformer,
132143
vpcNatGwInformer: vpcNatGwInformer,
133144
subnetInformer: subnetInformer,
145+
ipInformer: ipInformer,
134146
serviceInformer: serviceInformer,
135147
namespaceInformer: namespaceInformer,
136148
podInformer: podInformer,
@@ -141,17 +153,21 @@ func newFakeControllerWithOptions(t *testing.T, opts *FakeControllerOptions) (*f
141153

142154
// Create controller with all informers
143155
ctrl := &Controller{
144-
servicesLister: serviceInformer.Lister(),
145-
namespacesLister: namespaceInformer.Lister(),
146-
podsLister: podInformer.Lister(),
147-
vpcsLister: vpcInformer.Lister(),
148-
vpcSynced: alwaysReady,
149-
subnetsLister: subnetInformer.Lister(),
150-
subnetSynced: alwaysReady,
151-
netAttachLister: nadInformer.Lister(),
152-
netAttachSynced: alwaysReady,
153-
OVNNbClient: mockOvnClient,
154-
syncVirtualPortsQueue: newTypedRateLimitingQueue[string]("SyncVirtualPort", nil),
156+
servicesLister: serviceInformer.Lister(),
157+
namespacesLister: namespaceInformer.Lister(),
158+
podsLister: podInformer.Lister(),
159+
vpcsLister: vpcInformer.Lister(),
160+
vpcSynced: alwaysReady,
161+
subnetsLister: subnetInformer.Lister(),
162+
subnetSynced: alwaysReady,
163+
ipsLister: ipInformer.Lister(),
164+
ipSynced: alwaysReady,
165+
netAttachLister: nadInformer.Lister(),
166+
netAttachSynced: alwaysReady,
167+
OVNNbClient: mockOvnClient,
168+
ipam: ovnipam.NewIPAM(),
169+
syncVirtualPortsQueue: newTypedRateLimitingQueue[string]("SyncVirtualPort", nil),
170+
updateSubnetStatusQueue: newTypedRateLimitingQueue[string]("UpdateSubnetStatus", nil),
155171
}
156172

157173
ctrl.config = &Configuration{

pkg/controller/ip.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,12 @@ func (c *Controller) handleUpdateIP(key string) error {
238238
klog.Infof("handle deleting ip %s", cachedIP.Name)
239239
subnet, err := c.subnetsLister.Get(cachedIP.Spec.Subnet)
240240
if err != nil {
241-
klog.Errorf("failed to get subnet %s: %v", cachedIP.Spec.Subnet, err)
242-
return err
241+
if !k8serrors.IsNotFound(err) {
242+
klog.Errorf("failed to get subnet %s: %v", cachedIP.Spec.Subnet, err)
243+
return err
244+
}
245+
// subnet already deleted; isOvnSubnet(nil) returns false, skip OVN port cleanup
246+
subnet = nil
243247
}
244248
portName := cachedIP.Name
245249
if isOvnSubnet(subnet) {

pkg/controller/ip_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package controller
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
9+
kubeovnv1 "github.com/kubeovn/kube-ovn/pkg/apis/kubeovn/v1"
10+
"github.com/kubeovn/kube-ovn/pkg/util"
11+
)
12+
13+
func Test_handleUpdateIP_deletedSubnet(t *testing.T) {
14+
t.Parallel()
15+
16+
now := metav1.Now()
17+
ip := &kubeovnv1.IP{
18+
ObjectMeta: metav1.ObjectMeta{
19+
Name: "test-ip",
20+
DeletionTimestamp: &now,
21+
Finalizers: []string{util.KubeOVNControllerFinalizer},
22+
},
23+
Spec: kubeovnv1.IPSpec{
24+
Subnet: "deleted-subnet",
25+
Namespace: "default",
26+
PodName: "test-pod",
27+
},
28+
}
29+
30+
fakeCtrl, err := newFakeControllerWithOptions(t, &FakeControllerOptions{
31+
IPs: []*kubeovnv1.IP{ip},
32+
})
33+
require.NoError(t, err)
34+
35+
ctrl := fakeCtrl.fakeController
36+
37+
// Shut down work queues to avoid goroutine leaks
38+
t.Cleanup(func() {
39+
ctrl.updateSubnetStatusQueue.ShutDown()
40+
ctrl.syncVirtualPortsQueue.ShutDown()
41+
})
42+
43+
// The subnet "deleted-subnet" does not exist in the fake client.
44+
// This must not panic (previously caused NPE in isOvnSubnet).
45+
err = ctrl.handleUpdateIP("test-ip")
46+
require.NoError(t, err)
47+
48+
// Verify the subnet status update was enqueued
49+
require.Equal(t, 1, ctrl.updateSubnetStatusQueue.Len())
50+
}

pkg/controller/subnet.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1904,7 +1904,7 @@ func (c *Controller) releaseMcastQuerierIP(subnet *kubeovnv1.Subnet) (bool, erro
19041904
}
19051905

19061906
func isOvnSubnet(subnet *kubeovnv1.Subnet) bool {
1907-
return util.IsOvnProvider(subnet.Spec.Provider)
1907+
return subnet != nil && util.IsOvnProvider(subnet.Spec.Provider)
19081908
}
19091909

19101910
func formatExcludeIPRanges(subnet *kubeovnv1.Subnet) {

pkg/controller/subnet_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
kubeovnv1 "github.com/kubeovn/kube-ovn/pkg/apis/kubeovn/v1"
1414
"github.com/kubeovn/kube-ovn/pkg/ovsdb/ovnnb"
15+
"github.com/kubeovn/kube-ovn/pkg/util"
1516
)
1617

1718
func Test_reconcileVips(t *testing.T) {
@@ -274,3 +275,46 @@ func Test_formatSubnet(t *testing.T) {
274275
})
275276
}
276277
}
278+
279+
func Test_isOvnSubnet(t *testing.T) {
280+
t.Parallel()
281+
282+
tests := []struct {
283+
name string
284+
subnet *kubeovnv1.Subnet
285+
want bool
286+
}{
287+
{
288+
name: "nil subnet returns false",
289+
subnet: nil,
290+
want: false,
291+
},
292+
{
293+
name: "empty provider defaults to OVN",
294+
subnet: &kubeovnv1.Subnet{
295+
Spec: kubeovnv1.SubnetSpec{Provider: ""},
296+
},
297+
want: true,
298+
},
299+
{
300+
name: "explicit OVN provider",
301+
subnet: &kubeovnv1.Subnet{
302+
Spec: kubeovnv1.SubnetSpec{Provider: util.OvnProvider},
303+
},
304+
want: true,
305+
},
306+
{
307+
name: "non-OVN provider",
308+
subnet: &kubeovnv1.Subnet{
309+
Spec: kubeovnv1.SubnetSpec{Provider: "external.provider"},
310+
},
311+
want: false,
312+
},
313+
}
314+
315+
for _, tc := range tests {
316+
t.Run(tc.name, func(t *testing.T) {
317+
require.Equal(t, tc.want, isOvnSubnet(tc.subnet))
318+
})
319+
}
320+
}

0 commit comments

Comments
 (0)