Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ jobs:
/usr/local/bin/kubectl apply -f examples/deviceclass.yaml
/usr/local/bin/kubectl apply -f examples/resourceclaim.yaml
/usr/local/bin/kubectl wait --timeout=2m --for=condition=ready pods -l app=pod
/usr/local/bin/kubectl exec -it pod1 -- ip link show dummy0
/usr/local/bin/kubectl exec -it pod1 -- ip link show eth99

- name: Upload Junit Reports
if: always()
Expand Down
8 changes: 4 additions & 4 deletions examples/resourceclaim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ spec:
- opaque:
driver: dra.net
parameters:
newName: "eth99"
address: "192.168.2.2"
mask: "255.255.255.0"
mtu: "1500"
interface:
name: "eth99"
addresses:
- "169.254.169.13/32"
---
apiVersion: v1
kind: Pod
Expand Down
52 changes: 39 additions & 13 deletions examples/resourceclaimtemplate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,52 @@
# limitations under the License.
---
apiVersion: resource.k8s.io/v1beta1
kind: DeviceClass
metadata:
name: multinic
spec:
selectors:
- cel:
expression: device.driver == "dra.net"
---
apiVersion: resource.k8s.io/v1beta1
kind: ResourceClaimTemplate
metadata:
name: dummy-interfaces
name: phy-interfaces-template
spec:
spec:
devices:
requests:
- name: req-dummy-template
deviceClassName: dra.net
- name: phy-interfaces-template
deviceClassName: multinic
selectors:
- cel:
expression: device.attributes["dra.net"].ifName == "dummy1"
---
apiVersion: v1
kind: Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: pod0
name: server-deployment
labels:
app: pod
app: MyApp
spec:
containers:
- name: ctr0
image: registry.k8s.io/e2e-test-images/agnhost:2.39
resourceClaims:
- name: dummy
resourceClaimTemplateName: dummy-interfaces
replicas: 1
selector:
matchLabels:
app: MyApp
template:
metadata:
labels:
app: MyApp
spec:
resourceClaims:
- name: phy-interfaces
resourceClaimTemplateName: phy-interfaces-template
containers:
- name: agnhost
image: registry.k8s.io/e2e-test-images/agnhost:2.39
args:
- netexec
- --http-port=80
ports:
- containerPort: 80
38 changes: 38 additions & 0 deletions pkg/apis/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Copyright 2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package apis

// NetworkConfig represents the desired state of all network interfaces and their associated routes.
type NetworkConfig struct {
Interface InterfaceConfig `json:"interface"` // Changed to a slice to support multiple interfaces
Routes []RouteConfig `json:"routes"`
}

// InterfaceConfig represents the configuration for a single network interface.
type InterfaceConfig struct {
Name string `json:"name,omitempty"` // Logical name of the interface (e.g., "eth0", "enp0s3")
Addresses []string `json:"addresses,omitempty"` // IP addresses and their CIDR masks
MTU int32 `json:"mtu,omitempty"` // Maximum Transmission Unit, optional
HardwareAddr string `json:"hardwareAddr,omitempty"` // Read-only: Current hardware address (might be useful for GET)
}

// RouteConfig represents a network route configuration.
type RouteConfig struct {
Destination string `json:"destination,omitempty"` // e.g., "0.0.0.0/0" for default, "10.0.0.0/8"
Gateway string `json:"gateway,omitempty"` // The "gateway" address, e.g., "192.168.1.1"
Source string `json:"source,omitempty"` // Optional source address for policy routing
}
72 changes: 72 additions & 0 deletions pkg/apis/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
Copyright 2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package apis

import (
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"

"k8s.io/apimachinery/pkg/runtime"
)

// ValidateConfig validates the data in a runtime.RawExtension against the OpenAPI schema.
func ValidateConfig(raw *runtime.RawExtension) (*NetworkConfig, error) {
if raw == nil || raw.Raw == nil {
return nil, nil
}
// Check if raw.Raw is empty
if len(raw.Raw) == 0 {
return nil, nil
}
var errorsList []error
var config NetworkConfig
if err := json.Unmarshal(raw.Raw, &config); err != nil {
return nil, fmt.Errorf("failed to unmarshal YAML data: %w", err)
}

for _, ip := range config.Interface.Addresses {
if _, err := netip.ParsePrefix(ip); err != nil {
errorsList = append(errorsList, fmt.Errorf("invalid IP in CIDR format %s", ip))
}
}

// Validate routes
for i, route := range config.Routes {
if route.Destination == "" {
errorsList = append(errorsList, fmt.Errorf("route %d: destination cannot be empty", i))
} else {
// Validate Destination as CIDR or IP
if _, _, err := net.ParseCIDR(route.Destination); err != nil {
if net.ParseIP(route.Destination) == nil {
errorsList = append(errorsList, fmt.Errorf("route %d: invalid destination IP or CIDR '%s'", i, route.Destination))
}
}
}

if route.Gateway != "" {
if net.ParseIP(route.Gateway) == nil {
errorsList = append(errorsList, fmt.Errorf("route %d: invalid gateway IP '%s'", i, route.Gateway))
}
} else {
errorsList = append(errorsList, fmt.Errorf("route %d: for destination '%s' must have a gateway", i, route.Destination))
}
}
return &config, errors.Join(errorsList...)
}
163 changes: 163 additions & 0 deletions pkg/apis/validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
Copyright 2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package apis

import (
"strings"
"testing"

"k8s.io/apimachinery/pkg/runtime"
)

func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
raw *runtime.RawExtension
wantErr bool
errMsgs []string
}{
{
name: "valid config",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {
"name": "eth0",
"addresses": ["192.168.1.10/24", "2001:db8::1/64"],
"mtu": 1500
},
"routes": [
{
"destination": "0.0.0.0/0",
"gateway": "192.168.1.1"
},
{
"destination": "2001:db8:abcd::/48",
"gateway": "2001:db::1"
}
]
}`)},
wantErr: false,
},
{
name: "nil raw extension",
raw: nil,
wantErr: false,
},
{
name: "nil raw field in raw extension",
raw: &runtime.RawExtension{Raw: nil},
wantErr: false,
},
{
name: "empty raw field in raw extension",
raw: &runtime.RawExtension{Raw: []byte{}},
wantErr: false,
},
{
name: "malformed json",
raw: &runtime.RawExtension{Raw: []byte(`{"interface": {"name": "eth0"`)}, // Missing closing brace
wantErr: true,
errMsgs: []string{"failed to unmarshal YAML data"},
},
{
name: "invalid interface IP CIDR",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {
"name": "eth0",
"addresses": ["192.168.1.10/240"]
}
}`)},
wantErr: true,
errMsgs: []string{"invalid IP in CIDR format 192.168.1.10/240"},
},
{
name: "route with empty destination",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {"name": "eth0", "addresses": ["192.168.1.10/24"]},
"routes": [{"gateway": "192.168.1.1"}]
}`)},
wantErr: true,
errMsgs: []string{"route 0: destination cannot be empty"},
},
{
name: "route with invalid destination",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {"name": "eth0", "addresses": ["192.168.1.10/24"]},
"routes": [{"destination": "not-an-ip", "gateway": "192.168.1.1"}]
}`)},
wantErr: true,
errMsgs: []string{"route 0: invalid destination IP or CIDR 'not-an-ip'"},
},
{
name: "route with no gateway",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {"name": "eth0", "addresses": ["192.168.1.10/24"]},
"routes": [{"destination": "10.0.0.0/8"}]
}`)},
wantErr: true,
errMsgs: []string{"route 0: for destination '10.0.0.0/8' must have a gateway"},
},
{
name: "route with invalid gateway IP",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {"name": "eth0", "addresses": ["192.168.1.10/24"]},
"routes": [{"destination": "10.0.0.0/8", "gateway": "not-a-gateway"}]
}`)},
wantErr: true,
errMsgs: []string{"route 0: invalid gateway IP 'not-a-gateway'"},
},
{
name: "multiple errors",
raw: &runtime.RawExtension{Raw: []byte(`{
"interface": {
"name": "eth0",
"addresses": ["192.168.1.10/240", "10.0.0.1/invalid"]
},
"routes": [
{"destination": "", "gateway": "192.168.1.1"},
{"destination": "not-an-ip", "gateway": "192.168.1.1"},
{"destination": "10.0.0.0/8"},
{"destination": "10.0.1.0/24", "gateway": "not-a-gateway"}
]
}`)},
wantErr: true,
errMsgs: []string{
"invalid IP in CIDR format 192.168.1.10/240",
"invalid IP in CIDR format 10.0.0.1/invalid",
"route 0: destination cannot be empty",
"route 1: invalid destination IP or CIDR 'not-an-ip'",
"route 3: invalid gateway IP 'not-a-gateway'",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ValidateConfig(tt.raw)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
for _, errMsg := range tt.errMsgs {
if !strings.Contains(err.Error(), errMsg) {
t.Errorf("ValidateConfig() error = %v, want to contain %v", err, errMsg)
}
}
}
})
}
}
Loading
Loading