Skip to content

Commit 0a9b3d5

Browse files
committed
[ipam/azure] Sort CiliumNode status interfaces and addresses
Signed-off-by: Jared Ledvina <jared.ledvina@datadoghq.com>
1 parent e98edf6 commit 0a9b3d5

2 files changed

Lines changed: 206 additions & 4 deletions

File tree

pkg/azure/ipam/node.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
package ipam
55

66
import (
7+
"cmp"
78
"context"
89
"fmt"
910
"log/slog"
11+
"slices"
12+
"strings"
1013

1114
"github.com/cilium/cilium/pkg/azure/types"
1215
"github.com/cilium/cilium/pkg/defaults"
@@ -42,19 +45,42 @@ func (n *Node) UpdatedNode(obj *v2.CiliumNode) {
4245
}
4346

4447
// PopulateStatusFields fills in the status field of the CiliumNode custom
45-
// resource with Azure specific information
48+
// resource with Azure specific information.
49+
//
50+
// ForeachInterface iterates a map and Azure does not document an order for an
51+
// interface's IP configurations, so both levels are sorted into a total order:
52+
// an unchanged node then compares equal in
53+
// ciliumNodeUpdateImplementation.UpdateStatus and the /status write is skipped
4654
func (n *Node) PopulateStatusFields(k8sObj *v2.CiliumNode) {
47-
k8sObj.Status.Azure.Interfaces = []types.AzureInterface{}
55+
interfaces := []types.AzureInterface{}
4856

4957
n.manager.mutex.RLock()
5058
defer n.manager.mutex.RUnlock()
5159
n.manager.instances.ForeachInterface(n.node.InstanceID(), func(instanceID, interfaceID string, interfaceObj ipamTypes.InterfaceRevision) error {
5260
iface, ok := interfaceObj.Resource.(*types.AzureInterface)
53-
if ok {
54-
k8sObj.Status.Azure.Interfaces = append(k8sObj.Status.Azure.Interfaces, *(iface.DeepCopy()))
61+
if !ok {
62+
return nil
5563
}
64+
// Copied because the sorts below mutate in place and the instance
65+
// cache is only read locked.
66+
interfaces = append(interfaces, *iface.DeepCopy())
5667
return nil
5768
})
69+
70+
// ID alone is a total order: it is the instance's interface map key.
71+
slices.SortFunc(interfaces, func(a, b types.AzureInterface) int {
72+
return strings.Compare(a.ID, b.ID)
73+
})
74+
for i := range interfaces {
75+
slices.SortFunc(interfaces[i].Addresses, func(a, b types.AzureAddress) int {
76+
return cmp.Or(
77+
strings.Compare(a.IP, b.IP),
78+
strings.Compare(a.Subnet, b.Subnet),
79+
strings.Compare(a.State, b.State),
80+
)
81+
})
82+
}
83+
k8sObj.Status.Azure.Interfaces = interfaces
5884
}
5985

6086
// PrepareIPRelease prepares the release of IPs

pkg/azure/ipam/node_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,190 @@
44
package ipam
55

66
import (
7+
"encoding/json"
8+
"fmt"
9+
"reflect"
710
"testing"
811

912
"github.com/stretchr/testify/require"
1013

1114
"github.com/cilium/cilium/pkg/azure/types"
15+
ipamTypes "github.com/cilium/cilium/pkg/ipam/types"
16+
v2 "github.com/cilium/cilium/pkg/k8s/apis/cilium.io/v2"
1217
)
1318

1419
func TestGetMaximumAllocatableIPv4(t *testing.T) {
1520
n := &Node{}
1621
require.Equal(t, types.InterfaceAddressLimit, n.GetMaximumAllocatableIPv4())
1722
}
23+
24+
const statusTestIDFormat = "/subscriptions/xxx/resourceGroups/g1/providers/Microsoft.Compute/virtualMachineScaleSets/vmss1/virtualMachines/0/networkInterfaces/%s"
25+
26+
// PopulateStatusFields compares every AzureAddress field to keep the address
27+
// sort a total order.
28+
func TestAzureAddressFullyCompared(t *testing.T) {
29+
require.Equal(t, 3, reflect.TypeFor[types.AzureAddress]().NumField(),
30+
"AzureAddress gained a field; extend the address comparator in PopulateStatusFields")
31+
}
32+
33+
// Addresses share an IP so the comparator's Subnet and State tie-breakers each
34+
// decide an ordering, and SecurityGroup collates inversely to ID so sorting by
35+
// the wrong field is visible.
36+
func newStatusTestInterfaces() []*types.AzureInterface {
37+
return newStatusTestInterfacesWith([]types.AzureAddress{
38+
{IP: "10.0.0.2", Subnet: "s-1", State: types.StateSucceeded},
39+
{IP: "10.0.0.1", Subnet: "s-2", State: types.StateSucceeded},
40+
{IP: "10.0.0.1", Subnet: "s-1", State: types.StateSucceeded},
41+
{IP: "10.0.0.1", Subnet: "s-1", State: "failed"},
42+
})
43+
}
44+
45+
func newStatusTestInterfacesWith(addresses []types.AzureAddress) []*types.AzureInterface {
46+
names := []string{"nic-c", "nic-a", "nic-b"}
47+
var ifaces []*types.AzureInterface
48+
for i, name := range names {
49+
ifaces = append(ifaces, &types.AzureInterface{
50+
ID: fmt.Sprintf(statusTestIDFormat, name),
51+
SecurityGroup: fmt.Sprintf("sg-%d", len(names)-i),
52+
Addresses: addresses,
53+
})
54+
}
55+
return ifaces
56+
}
57+
58+
func statusTestIDs(names ...string) []string {
59+
ids := make([]string, 0, len(names))
60+
for _, name := range names {
61+
ids = append(ids, fmt.Sprintf(statusTestIDFormat, name))
62+
}
63+
return ids
64+
}
65+
66+
func newStatusTestNode(ifaces []*types.AzureInterface) *Node {
67+
m := ipamTypes.NewInstanceMap()
68+
for _, iface := range ifaces {
69+
m.Update("vm1", ipamTypes.InterfaceRevision{Resource: iface.DeepCopy()})
70+
}
71+
return &Node{
72+
node: mockIPAMNode("vm1"),
73+
manager: &InstancesManager{instances: m},
74+
}
75+
}
76+
77+
// Repeated calls must produce an identical status, and it must match the copy
78+
// the operator reads back from the apiserver, or
79+
// ciliumNodeUpdateImplementation.UpdateStatus writes /status on every sync.
80+
func TestPopulateStatusFieldsDeterministicOrder(t *testing.T) {
81+
node := newStatusTestNode(newStatusTestInterfaces())
82+
83+
fromAPIServer := &v2.CiliumNode{}
84+
node.PopulateStatusFields(fromAPIServer)
85+
marshalled, err := json.Marshal(fromAPIServer)
86+
require.NoError(t, err)
87+
fromAPIServer = &v2.CiliumNode{}
88+
require.NoError(t, json.Unmarshal(marshalled, fromAPIServer))
89+
90+
// ForeachInterface's map iteration order is randomized per call.
91+
for i := range 10 {
92+
k8sObj := &v2.CiliumNode{}
93+
node.PopulateStatusFields(k8sObj)
94+
95+
got := k8sObj.Status.Azure.Interfaces
96+
ids := make([]string, 0, len(got))
97+
for _, iface := range got {
98+
ids = append(ids, iface.ID)
99+
100+
addrs := make([]string, 0, len(iface.Addresses))
101+
for _, addr := range iface.Addresses {
102+
addrs = append(addrs, fmt.Sprintf("%s/%s/%s", addr.IP, addr.Subnet, addr.State))
103+
}
104+
require.Equal(t, []string{
105+
"10.0.0.1/s-1/failed",
106+
"10.0.0.1/s-1/" + types.StateSucceeded,
107+
"10.0.0.1/s-2/" + types.StateSucceeded,
108+
"10.0.0.2/s-1/" + types.StateSucceeded,
109+
}, addrs, "iteration %d: addresses not in total order", i)
110+
}
111+
require.Equal(t, statusTestIDs("nic-a", "nic-b", "nic-c"), ids, "iteration %d", i)
112+
113+
require.True(t, fromAPIServer.Status.DeepEqual(&k8sObj.Status),
114+
"iteration %d: no-op sync differs from the apiserver copy, forcing a /status write", i)
115+
}
116+
}
117+
118+
// Azure does not document an order for an interface's IP configurations, so a
119+
// reordered poll must still produce the same status.
120+
func TestPopulateStatusFieldsAddressOrderIndependent(t *testing.T) {
121+
addresses := []types.AzureAddress{
122+
{IP: "10.0.0.1", Subnet: "s-1", State: types.StateSucceeded},
123+
{IP: "10.0.0.2", Subnet: "s-1", State: types.StateSucceeded},
124+
{IP: "10.0.0.3", Subnet: "s-1", State: types.StateSucceeded},
125+
}
126+
reordered := []types.AzureAddress{addresses[2], addresses[0], addresses[1]}
127+
128+
first := &v2.CiliumNode{}
129+
newStatusTestNode(newStatusTestInterfacesWith(addresses)).PopulateStatusFields(first)
130+
131+
second := &v2.CiliumNode{}
132+
newStatusTestNode(newStatusTestInterfacesWith(reordered)).PopulateStatusFields(second)
133+
134+
require.True(t, first.Status.DeepEqual(&second.Status))
135+
}
136+
137+
func TestPopulateStatusFieldsReplacesStaleInterfaces(t *testing.T) {
138+
node := newStatusTestNode(newStatusTestInterfaces())
139+
140+
k8sObj := &v2.CiliumNode{}
141+
k8sObj.Status.Azure.Interfaces = []types.AzureInterface{{ID: "detached"}}
142+
node.PopulateStatusFields(k8sObj)
143+
144+
ids := make([]string, 0, len(k8sObj.Status.Azure.Interfaces))
145+
for _, iface := range k8sObj.Status.Azure.Interfaces {
146+
ids = append(ids, iface.ID)
147+
}
148+
require.Equal(t, statusTestIDs("nic-a", "nic-b", "nic-c"), ids)
149+
}
150+
151+
// The status is sorted in place, so it must be built from copies: the instance
152+
// cache is shared across nodes and read under a read lock.
153+
func TestPopulateStatusFieldsDoesNotMutateInstances(t *testing.T) {
154+
node := newStatusTestNode(newStatusTestInterfaces())
155+
156+
before := map[string]*types.AzureInterface{}
157+
node.manager.instances.ForeachInterface("vm1", func(_, interfaceID string, obj ipamTypes.InterfaceRevision) error {
158+
before[interfaceID] = obj.Resource.(*types.AzureInterface).DeepCopy()
159+
return nil
160+
})
161+
require.NotEmpty(t, before)
162+
163+
node.PopulateStatusFields(&v2.CiliumNode{})
164+
165+
node.manager.instances.ForeachInterface("vm1", func(_, interfaceID string, obj ipamTypes.InterfaceRevision) error {
166+
require.True(t, before[interfaceID].DeepEqual(obj.Resource.(*types.AzureInterface)),
167+
"cached interface %s mutated by PopulateStatusFields", interfaceID)
168+
return nil
169+
})
170+
}
171+
172+
// An empty status is the common bootstrap shape, and it must round trip too:
173+
// omitempty drops the slice, so the apiserver copy reads back as nil.
174+
func TestPopulateStatusFieldsNoInterfaces(t *testing.T) {
175+
for name, ifaces := range map[string][]*types.AzureInterface{
176+
"no interfaces": nil,
177+
"no addresses": newStatusTestInterfacesWith(nil),
178+
} {
179+
t.Run(name, func(t *testing.T) {
180+
node := newStatusTestNode(ifaces)
181+
182+
k8sObj := &v2.CiliumNode{}
183+
node.PopulateStatusFields(k8sObj)
184+
185+
marshalled, err := json.Marshal(k8sObj)
186+
require.NoError(t, err)
187+
fromAPIServer := &v2.CiliumNode{}
188+
require.NoError(t, json.Unmarshal(marshalled, fromAPIServer))
189+
190+
require.True(t, fromAPIServer.Status.DeepEqual(&k8sObj.Status))
191+
})
192+
}
193+
}

0 commit comments

Comments
 (0)