Skip to content

Commit 6b5a84d

Browse files
committed
Add Nokia SR Linux DHCP relay provider
Implements EnsureDHCPRelay and DeleteDHCPRelay for the SR Linux provider using native Nokia YANG models. Supports per-subinterface relay and optional cross-VRF forwarding via vrfRef. Signed-off-by: Vasyl Paliuha <vasyl.paliuha@sap.com>
1 parent 470bb2a commit 6b5a84d

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package srlinux
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strconv"
10+
"strings"
11+
12+
corev1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
13+
"github.com/ironcore-dev/network-operator/internal/provider"
14+
"github.com/ironcore-dev/network-operator/internal/transport/gnmiext"
15+
)
16+
17+
const (
18+
// dhcpRelayDefaultSubinterfaceIndex is the subinterface a relay attaches to
19+
// when the interface name carries no ".<index>" suffix.
20+
dhcpRelayDefaultSubinterfaceIndex = 0
21+
22+
// dhcpRelayAdminStateEnable is the admin-state value that enables the relay.
23+
// It is also the platform default, so it must be set explicitly to avoid a
24+
// false diff against the device on every reconcile.
25+
dhcpRelayAdminStateEnable = "enable"
26+
)
27+
28+
var _ provider.DHCPRelayProvider = (*Provider)(nil)
29+
30+
// DeleteDHCPRelay implements [provider.DHCPRelayProvider].
31+
func (p *Provider) DeleteDHCPRelay(ctx context.Context, req *provider.DeleteDHCPRelayRequest) error {
32+
// The deprecated InterfaceRefs path provides no resolved interface in the
33+
// delete request. SRLinux models the relay per subinterface with no single
34+
// root to remove, so there is nothing to delete here.
35+
if len(req.DHCPRelay.Spec.InterfaceRefs) > 0 { //nolint:staticcheck
36+
return nil
37+
}
38+
name, index, err := splitSubinterface(req.Interface.Spec.Name)
39+
if err != nil {
40+
return err
41+
}
42+
return p.client.Delete(ctx, &DHCPRelay{Interface: name, SubinterfaceIndex: index})
43+
}
44+
45+
// EnsureDHCPRelay implements [provider.DHCPRelayProvider].
46+
func (p *Provider) EnsureDHCPRelay(ctx context.Context, req *provider.DHCPRelayRequest) error {
47+
var networkInstance string
48+
if req.VRF != nil {
49+
networkInstance = req.VRF.Spec.Name
50+
}
51+
52+
ifaces := req.Interfaces
53+
if req.Interface != nil {
54+
ifaces = []corev1alpha1.Interface{*req.Interface}
55+
}
56+
sb := &gnmiext.SetBuilder{}
57+
for i := range ifaces {
58+
name, index, err := splitSubinterface(ifaces[i].Spec.Name)
59+
if err != nil {
60+
return err
61+
}
62+
sb.Update(&DHCPRelay{
63+
Interface: name,
64+
SubinterfaceIndex: index,
65+
AdminState: dhcpRelayAdminStateEnable,
66+
Server: req.DHCPRelay.Spec.Servers,
67+
NetworkInstance: networkInstance,
68+
})
69+
}
70+
71+
return p.client.Do(ctx, sb)
72+
}
73+
74+
// splitSubinterface splits an interface name into its base name and
75+
// subinterface index. SRLinux names a subinterface "<interface>.<index>";
76+
// without a suffix the relay attaches to the base subinterface.
77+
func splitSubinterface(name string) (string, int, error) {
78+
i := strings.LastIndex(name, ".")
79+
if i == -1 {
80+
return name, dhcpRelayDefaultSubinterfaceIndex, nil
81+
}
82+
index, err := strconv.Atoi(name[i+1:])
83+
if err != nil {
84+
return "", 0, fmt.Errorf("invalid subinterface index in interface name %q: %w", name, err)
85+
}
86+
return name[:i], index, nil
87+
}
88+
89+
type DHCPRelay struct {
90+
Interface string `json:"-"`
91+
SubinterfaceIndex int `json:"-"`
92+
AdminState string `json:"admin-state,omitempty"`
93+
Server []string `json:"server,omitempty"`
94+
NetworkInstance string `json:"network-instance,omitempty"`
95+
}
96+
97+
func (d *DHCPRelay) XPath() string {
98+
return fmt.Sprintf("srl_nokia-interfaces:interface[name=%s]/subinterface[index=%d]/ipv4/srl_nokia-interfaces-ip-dhcp-relay:dhcp-relay", d.Interface, d.SubinterfaceIndex)
99+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package srlinux
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"strings"
10+
"testing"
11+
12+
corev1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
13+
"github.com/ironcore-dev/network-operator/internal/provider"
14+
"github.com/ironcore-dev/network-operator/internal/transport/gnmiext"
15+
)
16+
17+
func newProviderWithClient(client gnmiext.Client) *Provider {
18+
return &Provider{client: client}
19+
}
20+
21+
// captureRelays returns a mock whose Do records every DHCPRelay element queued.
22+
func captureRelays(relays *[]*DHCPRelay) *gnmiext.ClientMock {
23+
return &gnmiext.ClientMock{
24+
DoFunc: func(_ context.Context, b *gnmiext.SetBuilder) error {
25+
b.Split(func(el gnmiext.DataElement) bool {
26+
if r, ok := el.(*DHCPRelay); ok {
27+
*relays = append(*relays, r)
28+
}
29+
return false
30+
})
31+
return nil
32+
},
33+
}
34+
}
35+
36+
func TestEnsureDHCPRelayNetworkInstance(t *testing.T) {
37+
tests := []struct {
38+
name string
39+
vrf *corev1alpha1.VRF
40+
want string
41+
}{
42+
{
43+
name: "no vrf leaves network-instance empty",
44+
vrf: nil,
45+
want: "",
46+
},
47+
{
48+
name: "vrf sets network-instance to the device vrf name",
49+
vrf: &corev1alpha1.VRF{Spec: corev1alpha1.VRFSpec{Name: "shared-services"}},
50+
want: "shared-services",
51+
},
52+
}
53+
for _, test := range tests {
54+
t.Run(test.name, func(t *testing.T) {
55+
var relays []*DHCPRelay
56+
p := newProviderWithClient(captureRelays(&relays))
57+
58+
err := p.EnsureDHCPRelay(t.Context(), &provider.DHCPRelayRequest{
59+
DHCPRelay: &corev1alpha1.DHCPRelay{
60+
Spec: corev1alpha1.DHCPRelaySpec{Servers: []string{"192.0.2.1"}},
61+
},
62+
Interface: &corev1alpha1.Interface{
63+
Spec: corev1alpha1.InterfaceSpec{Name: "ethernet-1/1"},
64+
},
65+
VRF: test.vrf,
66+
})
67+
if err != nil {
68+
t.Fatalf("EnsureDHCPRelay() error = %v", err)
69+
}
70+
if len(relays) != 1 {
71+
t.Fatalf("queued %d relays, want 1", len(relays))
72+
}
73+
if got := relays[0].NetworkInstance; got != test.want {
74+
t.Errorf("NetworkInstance = %q, want %q", got, test.want)
75+
}
76+
})
77+
}
78+
}
79+
80+
// TestDHCPRelayJSONNetworkInstance guards the omitempty contract: an unset
81+
// network-instance must be absent from the payload so periodic reconciles do
82+
// not diff against the device default and trigger a spurious Set.
83+
func TestDHCPRelayJSONNetworkInstance(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
relay *DHCPRelay
87+
wantKey bool
88+
}{
89+
{
90+
name: "empty network-instance is omitted",
91+
relay: &DHCPRelay{AdminState: dhcpRelayAdminStateEnable, Server: []string{"192.0.2.1"}},
92+
wantKey: false,
93+
},
94+
{
95+
name: "set network-instance is present",
96+
relay: &DHCPRelay{AdminState: dhcpRelayAdminStateEnable, Server: []string{"192.0.2.1"}, NetworkInstance: "shared-services"},
97+
wantKey: true,
98+
},
99+
}
100+
for _, test := range tests {
101+
t.Run(test.name, func(t *testing.T) {
102+
got, err := json.Marshal(test.relay)
103+
if err != nil {
104+
t.Fatalf("json.Marshal() error = %v", err)
105+
}
106+
if hasKey := strings.Contains(string(got), `"network-instance"`); hasKey != test.wantKey {
107+
t.Errorf("json.Marshal() = %s, network-instance present = %v, want %v", got, hasKey, test.wantKey)
108+
}
109+
})
110+
}
111+
}
112+
113+
func TestDHCPRelayXPath(t *testing.T) {
114+
tests := []struct {
115+
name string
116+
relay *DHCPRelay
117+
want string
118+
}{
119+
{
120+
name: "base subinterface",
121+
relay: &DHCPRelay{Interface: "ethernet-1/1"},
122+
want: "srl_nokia-interfaces:interface[name=ethernet-1/1]/subinterface[index=0]/ipv4/srl_nokia-interfaces-ip-dhcp-relay:dhcp-relay",
123+
},
124+
{
125+
name: "explicit subinterface index",
126+
relay: &DHCPRelay{Interface: "ethernet-1/1", SubinterfaceIndex: 100},
127+
want: "srl_nokia-interfaces:interface[name=ethernet-1/1]/subinterface[index=100]/ipv4/srl_nokia-interfaces-ip-dhcp-relay:dhcp-relay",
128+
},
129+
}
130+
for _, test := range tests {
131+
t.Run(test.name, func(t *testing.T) {
132+
if got := test.relay.XPath(); got != test.want {
133+
t.Errorf("XPath() = %q, want %q", got, test.want)
134+
}
135+
})
136+
}
137+
}
138+
139+
func TestSplitSubinterface(t *testing.T) {
140+
tests := []struct {
141+
name string
142+
in string
143+
wantIface string
144+
wantIndex int
145+
wantErr bool
146+
}{
147+
{name: "no suffix defaults to zero", in: "ethernet-1/1", wantIface: "ethernet-1/1", wantIndex: 0},
148+
{name: "explicit subinterface", in: "ethernet-1/1.100", wantIface: "ethernet-1/1", wantIndex: 100},
149+
{name: "loopback", in: "lo0", wantIface: "lo0", wantIndex: 0},
150+
{name: "non-numeric suffix errors", in: "ethernet-1/1.abc", wantErr: true},
151+
}
152+
for _, test := range tests {
153+
t.Run(test.name, func(t *testing.T) {
154+
iface, index, err := splitSubinterface(test.in)
155+
if test.wantErr {
156+
if err == nil {
157+
t.Fatal("expected error, got nil")
158+
}
159+
return
160+
}
161+
if err != nil {
162+
t.Fatalf("splitSubinterface(%q) error = %v", test.in, err)
163+
}
164+
if iface != test.wantIface || index != test.wantIndex {
165+
t.Errorf("splitSubinterface(%q) = (%q, %d), want (%q, %d)", test.in, iface, index, test.wantIface, test.wantIndex)
166+
}
167+
})
168+
}
169+
}
170+
171+
func TestEnsureDHCPRelaySubinterface(t *testing.T) {
172+
var relays []*DHCPRelay
173+
p := newProviderWithClient(captureRelays(&relays))
174+
175+
err := p.EnsureDHCPRelay(t.Context(), &provider.DHCPRelayRequest{
176+
DHCPRelay: &corev1alpha1.DHCPRelay{
177+
Spec: corev1alpha1.DHCPRelaySpec{Servers: []string{"192.0.2.1"}},
178+
},
179+
Interface: &corev1alpha1.Interface{
180+
Spec: corev1alpha1.InterfaceSpec{Name: "ethernet-1/1.100"},
181+
},
182+
})
183+
if err != nil {
184+
t.Fatalf("EnsureDHCPRelay() error = %v", err)
185+
}
186+
if len(relays) != 1 {
187+
t.Fatalf("queued %d relays, want 1", len(relays))
188+
}
189+
if relays[0].Interface != "ethernet-1/1" || relays[0].SubinterfaceIndex != 100 {
190+
t.Errorf("relay = (%q, %d), want (ethernet-1/1, 100)", relays[0].Interface, relays[0].SubinterfaceIndex)
191+
}
192+
}
193+
194+
func TestDeleteDHCPRelay(t *testing.T) {
195+
t.Run("normal path deletes the interface relay", func(t *testing.T) {
196+
var deleted []*DHCPRelay
197+
client := &gnmiext.ClientMock{
198+
DeleteFunc: func(_ context.Context, els ...gnmiext.DataElement) error {
199+
for _, el := range els {
200+
if r, ok := el.(*DHCPRelay); ok {
201+
deleted = append(deleted, r)
202+
}
203+
}
204+
return nil
205+
},
206+
}
207+
p := newProviderWithClient(client)
208+
209+
err := p.DeleteDHCPRelay(t.Context(), &provider.DeleteDHCPRelayRequest{
210+
DHCPRelay: &corev1alpha1.DHCPRelay{},
211+
Interface: &corev1alpha1.Interface{Spec: corev1alpha1.InterfaceSpec{Name: "ethernet-1/1.100"}},
212+
})
213+
if err != nil {
214+
t.Fatalf("DeleteDHCPRelay() error = %v", err)
215+
}
216+
if len(deleted) != 1 || deleted[0].Interface != "ethernet-1/1" || deleted[0].SubinterfaceIndex != 100 {
217+
t.Fatalf("deleted = %+v, want one relay for ethernet-1/1 subinterface 100", deleted)
218+
}
219+
})
220+
221+
t.Run("deprecated InterfaceRefs path is a no-op and does not panic", func(t *testing.T) {
222+
// Mock with no funcs: any device call panics, and req.Interface is nil.
223+
p := newProviderWithClient(&gnmiext.ClientMock{})
224+
225+
err := p.DeleteDHCPRelay(t.Context(), &provider.DeleteDHCPRelayRequest{
226+
DHCPRelay: &corev1alpha1.DHCPRelay{
227+
Spec: corev1alpha1.DHCPRelaySpec{
228+
InterfaceRefs: []corev1alpha1.LocalObjectReference{{Name: "eth1"}},
229+
},
230+
},
231+
})
232+
if err != nil {
233+
t.Fatalf("DeleteDHCPRelay() error = %v", err)
234+
}
235+
})
236+
}

0 commit comments

Comments
 (0)