diff --git a/Tiltfile b/Tiltfile index 75a8f3a23..b9820553f 100644 --- a/Tiltfile +++ b/Tiltfile @@ -207,6 +207,9 @@ k8s_resource(new_name='mac-entry', objects=['mac-entry:probe'], trigger_mode=TRI k8s_resource(new_name='route-prefix', objects=['route-prefix:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='vtep-peers', objects=['vtep-peers:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_yaml('./config/samples/nokia/srlinux/v1alpha1_ntp.yaml') +k8s_resource(new_name='ntp-srlinux', objects=['ntp-srlinux:ntp'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['srlinux']) + print('🚀 network-operator development environment') print('👉 Edit the code inside the api/, cmd/, or internal/ directories') print('👉 Tilt will automatically rebuild and redeploy when changes are detected') diff --git a/config/samples/nokia/srlinux/v1alpha1_ntp.yaml b/config/samples/nokia/srlinux/v1alpha1_ntp.yaml new file mode 100644 index 000000000..b839d0d26 --- /dev/null +++ b/config/samples/nokia/srlinux/v1alpha1_ntp.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NTP +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: ntp-srlinux +spec: + deviceRef: + name: leaf1 + sourceInterfaceName: mgmt0 + servers: + - address: de.pool.ntp.org + prefer: true + vrfName: mgmt + - address: pool.ntp.org + prefer: false + vrfName: mgmt diff --git a/examples/openconfig-containerlab/.gitignore b/examples/openconfig-containerlab/.gitignore new file mode 100644 index 000000000..b1411b9a2 --- /dev/null +++ b/examples/openconfig-containerlab/.gitignore @@ -0,0 +1 @@ +clab-nav-srl diff --git a/examples/openconfig-containerlab/topology.clab.yml b/examples/openconfig-containerlab/topology.clab.yml new file mode 100644 index 000000000..e2a5f4287 --- /dev/null +++ b/examples/openconfig-containerlab/topology.clab.yml @@ -0,0 +1,14 @@ +name: nav-srl + +topology: + nodes: + leaf1: + kind: nokia_srlinux + image: ghcr.io/nokia/srlinux:26.7.1 + startup-config: |- + system name host-name srl + system aaa authentication admin-user password admin + system grpc-server mgmt yang-models openconfig + ports: + - 8022:22 + - 9339:57400 diff --git a/internal/provider/openconfig/interface.go b/internal/provider/openconfig/interface.go index 6e77112eb..ed634676d 100644 --- a/internal/provider/openconfig/interface.go +++ b/internal/provider/openconfig/interface.go @@ -6,6 +6,7 @@ package openconfig import ( "context" "encoding/json" + "errors" "fmt" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" @@ -664,3 +665,27 @@ type SubinterfaceVlanDoubleTaggedConfig struct { InnerVlanID uint16 `json:"inner-vlan-id,omitempty"` OuterVlanID uint16 `json:"outer-vlan-id,omitempty"` } + +type interfaceAddrs struct { + ifName string + Address gnmiext.List[string, *IPv4Address] `json:"address"` +} + +func (a *interfaceAddrs) XPath() string { + return fmt.Sprintf("openconfig-interfaces:interfaces/interface[name=%s]/subinterfaces/subinterface[index=0]/openconfig-if-ip:ipv4/addresses", a.ifName) +} + +// interfaceIPAddr retrieves the first IPv4 address from the state of the named interface. +func (p *Provider) interfaceIPAddr(ctx context.Context, name string) (string, error) { + addrs := &interfaceAddrs{ifName: name} + if err := p.client.GetState(ctx, addrs); err != nil { + if errors.Is(err, gnmiext.ErrNil) { + return "", apistatus.NewFailedPreconditionError(fmt.Sprintf("interface %q has no IPv4 address", name)) + } + return "", fmt.Errorf("failed to get IPv4 address for interface %q: %w", name, err) + } + for _, a := range addrs.Address { + return a.IP, nil + } + return "", apistatus.NewFailedPreconditionError(fmt.Sprintf("interface %q has no IPv4 address", name)) +} diff --git a/internal/provider/openconfig/ntp.go b/internal/provider/openconfig/ntp.go new file mode 100644 index 000000000..534163ffc --- /dev/null +++ b/internal/provider/openconfig/ntp.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.NTPProvider = (*Provider)(nil) + +func (p *Provider) EnsureNTP(ctx context.Context, req *provider.EnsureNTPRequest) error { + spec := req.NTP.Spec + + n := &NTP{ + Config: &NTPConfig{ + Enabled: spec.AdminState == v1alpha1.AdminStateUp, + }, + } + + var sourceAddress string + if spec.SourceInterfaceName != "" { + var err error + sourceAddress, err = p.interfaceIPAddr(ctx, spec.SourceInterfaceName) + if err != nil { + return err + } + } + + if len(spec.Servers) > 0 { + n.Servers = &NTPServers{} + for _, s := range spec.Servers { + n.Servers.Server.Set(&NTPServer{ + Address: s.Address, + Config: &NTPServerConfig{ + Address: s.Address, + Prefer: s.Prefer, + NetworkInstance: s.VrfName, + SourceAddress: sourceAddress, + }, + }) + } + } + + return p.client.Update(ctx, n) +} + +func (p *Provider) DeleteNTP(ctx context.Context) error { + return p.client.Delete(ctx, &NTP{}) +} + +// Compile-time assertions. +var _ gnmiext.DataElement = (*NTP)(nil) + +// DNS represents the OpenConfig /system/ntp container. +type NTP struct { + Config *NTPConfig `json:"config"` + Servers *NTPServers `json:"servers"` +} + +func (*NTP) XPath() string { return "openconfig-system:system/ntp" } + +// NTPConfig holds the config container for NTP. +type NTPConfig struct { + Enabled bool `json:"enabled"` +} + +// NTPServers holds the servers container for NTP. +type NTPServers struct { + Server gnmiext.List[string, *NTPServer] `json:"server"` +} + +// NTPServer represents a single NTP server entry. +type NTPServer struct { + Address string `json:"address"` + Config *NTPServerConfig `json:"config"` +} + +func (s *NTPServer) Key() string { return s.Address } + +// NTPServerConfig holds the config container for a NTP server. +type NTPServerConfig struct { + Address string `json:"address"` + Prefer bool `json:"prefer"` + NetworkInstance string `json:"network-instance,omitempty"` // Maps to VrfName + SourceAddress string `json:"source-address,omitempty"` +} diff --git a/test/gnmi/testdata/openconfig/ntp.txtar b/test/gnmi/testdata/openconfig/ntp.txtar new file mode 100644 index 000000000..1c4c627c2 --- /dev/null +++ b/test/gnmi/testdata/openconfig/ntp.txtar @@ -0,0 +1,192 @@ +# NTP +-- ntp/ntp -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NTP +metadata: + name: ntp +spec: + deviceRef: + name: leaf1 + sourceInterfaceName: mgmt0 + servers: + - address: de.pool.ntp.org + prefer: true + vrfName: mgmt + - address: pool.ntp.org + prefer: false + vrfName: mgmt + +-- state/preload -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, + "openconfig-interfaces:interfaces": { + "interface": [ + { + "name": "mgmt0", + "config": { + "enabled": true, + "name": "mgmt0", + "type": "iana-if-type:ethernetCsmacd" + }, + "subinterfaces": { + "subinterface": [ + { + "index": 0, + "config": { + "enabled": true, + "index": 0 + }, + "openconfig-if-ip:ipv4": { + "addresses": { + "address": [ + { + "ip": "172.20.20.2", + "state": { + "ip": "172.20.20.2", + "origin": "DHCP", + "prefix-length": 24 + } + } + ] + }, + "config": { + "enabled": true + } + } + } + ] + } + } + ] + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + }, + "ntp": { + "config": { + "enabled": true + }, + "servers": { + "server": [ + { + "address": "de.pool.ntp.org", + "config": { + "address": "de.pool.ntp.org", + "prefer": true, + "network-instance": "mgmt", + "source-address": "172.20.20.2" + } + }, + { + "address": "pool.ntp.org", + "config": { + "address": "pool.ntp.org", + "prefer": false, + "network-instance": "mgmt", + "source-address": "172.20.20.2" + } + } + ] + } + } + }, + "openconfig-interfaces:interfaces": { + "interface": [ + { + "name": "mgmt0", + "config": { + "enabled": true, + "name": "mgmt0", + "type": "iana-if-type:ethernetCsmacd" + }, + "subinterfaces": { + "subinterface": [ + { + "index": 0, + "config": { + "enabled": true, + "index": 0 + }, + "openconfig-if-ip:ipv4": { + "addresses": { + "address": [ + { + "ip": "172.20.20.2", + "state": { + "ip": "172.20.20.2", + "origin": "DHCP", + "prefix-length": 24 + } + } + ] + }, + "config": { + "enabled": true + } + } + } + ] + } + } + ] + } +} + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, + "openconfig-interfaces:interfaces": { + "interface": [ + { + "name": "mgmt0", + "config": { + "enabled": true, + "name": "mgmt0", + "type": "iana-if-type:ethernetCsmacd" + }, + "subinterfaces": { + "subinterface": [ + { + "index": 0, + "config": { + "enabled": true, + "index": 0 + }, + "openconfig-if-ip:ipv4": { + "addresses": { + "address": [ + { + "ip": "172.20.20.2", + "state": { + "ip": "172.20.20.2", + "origin": "DHCP", + "prefix-length": 24 + } + } + ] + }, + "config": { + "enabled": true + } + } + } + ] + } + } + ] + } +} +