diff --git a/PROJECT b/PROJECT
index bfd3e47d5..be2d8eb2d 100644
--- a/PROJECT
+++ b/PROJECT
@@ -406,4 +406,12 @@ resources:
kind: Fabric
path: github.com/ironcore-dev/network-operator/api/evpn/v1alpha1
version: v1alpha1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: networking.metal.ironcore.dev
+ kind: Probe
+ path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
+ version: v1alpha1
version: "3"
diff --git a/Tiltfile b/Tiltfile
index 304db082c..5824f9e5b 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -200,6 +200,12 @@ k8s_resource(new_name='claim-prefix', objects=['claim-prefix:claim'], resource_d
k8s_yaml('./config/samples/v1alpha1_fabric.yaml')
k8s_resource(new_name='fabric', objects=['fabric:fabric', 'loopback-pool:ipaddresspool', 'underlay-p2p-pool:ipprefixpool'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+k8s_yaml('./config/samples/v1alpha1_probe.yaml')
+k8s_resource(new_name='ping-peer', objects=['ping-peer:probe'], resource_deps=['lo0'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+k8s_resource(new_name='mac-entry', objects=['mac-entry:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+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'])
+
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/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go
index 57d2fac6d..e07df5bff 100644
--- a/api/core/v1alpha1/groupversion_info.go
+++ b/api/core/v1alpha1/groupversion_info.go
@@ -212,6 +212,18 @@ const (
StorageThresholdExceededReason = "StorageThresholdExceeded"
)
+// Reasons that are specific to [Probe] objects.
+const (
+ // ProbeSuccessfulReason indicates that the probe assertion passed.
+ ProbeSuccessfulReason = "ProbeSuccessful"
+
+ // ProbeFailedReason indicates that the probe assertion did not pass.
+ ProbeFailedReason = "ProbeFailed"
+
+ // ProbeErrorReason indicates that the probe could not be executed.
+ ProbeErrorReason = "ProbeError"
+)
+
// Reasons that are specific to [Interface] objects.
const (
// InterfaceNotFoundReason indicates that a referenced interface was not found.
diff --git a/api/core/v1alpha1/probe_types.go b/api/core/v1alpha1/probe_types.go
new file mode 100644
index 000000000..109ad9da3
--- /dev/null
+++ b/api/core/v1alpha1/probe_types.go
@@ -0,0 +1,301 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha1
+
+import (
+ "sync"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// ProbeSpec defines the desired state of Probe.
+// +kubebuilder:validation:XValidation:rule="self.type != 'Ping' || has(self.ping)",message="ping must be specified when type is Ping"
+// +kubebuilder:validation:XValidation:rule="self.type == 'Ping' || !has(self.ping)",message="ping must be omitted when type is not Ping"
+// +kubebuilder:validation:XValidation:rule="self.type != 'MACTableEntry' || has(self.macTableEntry)",message="macTableEntry must be specified when type is MACTableEntry"
+// +kubebuilder:validation:XValidation:rule="self.type == 'MACTableEntry' || !has(self.macTableEntry)",message="macTableEntry must be omitted when type is not MACTableEntry"
+// +kubebuilder:validation:XValidation:rule="self.type != 'RoutePresence' || has(self.routePresence)",message="routePresence must be specified when type is RoutePresence"
+// +kubebuilder:validation:XValidation:rule="self.type == 'RoutePresence' || !has(self.routePresence)",message="routePresence must be omitted when type is not RoutePresence"
+// +kubebuilder:validation:XValidation:rule="self.type != 'VTEPPeerConnectivity' || has(self.vtepPeerConnectivity)",message="vtepPeerConnectivity must be specified when type is VTEPPeerConnectivity"
+// +kubebuilder:validation:XValidation:rule="self.type == 'VTEPPeerConnectivity' || !has(self.vtepPeerConnectivity)",message="vtepPeerConnectivity must be omitted when type is not VTEPPeerConnectivity"
+type ProbeSpec struct {
+ // DeviceRef is a reference to the Device this probe targets.
+ // The Device object must exist in the same namespace.
+ // Immutable.
+ // +required
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable"
+ DeviceRef LocalObjectReference `json:"deviceRef"`
+
+ // ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this probe.
+ // This reference is used to link the Probe to its provider-specific configuration.
+ // +optional
+ ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"`
+
+ // Schedule is an optional cron expression (e.g., "*/5 * * * *").
+ // If omitted, the controller performs a one-shot probe execution only once
+ // for the Probe resource; it does not re-execute on subsequent reconciliations.
+ // If set, the controller executes the probe periodically according to the schedule.
+ // +optional
+ Schedule string `json:"schedule,omitempty"`
+
+ // Type selects which probe assertion to execute.
+ // +required
+ Type ProbeType `json:"type"`
+
+ // Ping configures an ICMP echo probe.
+ // Required when type is Ping, must be omitted otherwise.
+ // +optional
+ Ping *PingProbe `json:"ping,omitempty"`
+
+ // MACTableEntry configures a MAC address table lookup probe.
+ // Required when type is MACTableEntry, must be omitted otherwise.
+ // +optional
+ MACTableEntry *MACTableEntryProbe `json:"macTableEntry,omitempty"`
+
+ // RoutePresence configures a routing table prefix lookup probe.
+ // Required when type is RoutePresence, must be omitted otherwise.
+ // +optional
+ RoutePresence *RoutePresenceProbe `json:"routePresence,omitempty"`
+
+ // VTEPPeerConnectivity configures a VTEP peer connectivity probe.
+ // Required when type is VTEPPeerConnectivity, must be omitted otherwise.
+ // +optional
+ VTEPPeerConnectivity *VTEPPeerConnectivityProbe `json:"vtepPeerConnectivity,omitempty"`
+}
+
+// ProbeType selects which assertion a Probe executes.
+// +kubebuilder:validation:Enum=Ping;MACTableEntry;RoutePresence;VTEPPeerConnectivity
+type ProbeType string
+
+const (
+ // ProbeTypePing sends ICMP echo requests from the device to a target address.
+ ProbeTypePing ProbeType = "Ping"
+ // ProbeTypeMACTableEntry asserts that a specific MAC address exists in the device's MAC table.
+ ProbeTypeMACTableEntry ProbeType = "MACTableEntry"
+ // ProbeTypeRoutePresence asserts that an IP prefix exists in a routing table.
+ ProbeTypeRoutePresence ProbeType = "RoutePresence"
+ // ProbeTypeVTEPPeerConnectivity asserts that expected remote VTEP peers are present and up.
+ ProbeTypeVTEPPeerConnectivity ProbeType = "VTEPPeerConnectivity"
+)
+
+// PingProbe configures an ICMP echo probe from the device to a target address.
+type PingProbe struct {
+ // Address is the target IPv4 or IPv6 address to ping.
+ // +required
+ Address IPAddr `json:"address"`
+
+ // SourceInterface selects the source interface for the ping.
+ // The provider uses an address on this interface with the same IP family as Address.
+ // If omitted, the device selects the source interface automatically.
+ // +optional
+ SourceInterface *InterfaceSource `json:"sourceInterface,omitempty"`
+
+ // VRF selects the VRF context in which to execute the ping.
+ // If omitted, the ping is executed in the default/global routing table.
+ // +optional
+ VRF *VRFSource `json:"vrf,omitempty"`
+
+ // Count is the number of ICMP echo requests to send.
+ // +optional
+ // +kubebuilder:default=3
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=100
+ Count *int32 `json:"count,omitempty"`
+
+ // PacketSize is the ICMP payload size in bytes.
+ // Useful for detecting MTU issues in VXLAN overlays.
+ // +optional
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=65507
+ PacketSize *int32 `json:"packetSize,omitempty"`
+
+ // Timeout is the maximum time to wait for a reply per echo request.
+ // +optional
+ Timeout *metav1.Duration `json:"timeout,omitempty"`
+}
+
+// MACTableEntryProbe asserts that a specific MAC address exists in the device's forwarding table.
+type MACTableEntryProbe struct {
+ // MACAddress is the MAC address to look for in the device's MAC table.
+ // +required
+ // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`
+ MACAddress string `json:"macAddress"`
+
+ // VLAN constrains the lookup to a specific VLAN.
+ // +optional
+ VLAN *VLANSource `json:"vlan,omitempty"`
+}
+
+// RoutePresenceProbe asserts that an IP prefix exists in the device's routing table.
+type RoutePresenceProbe struct {
+ // Prefix is the IP prefix to check for (e.g., "10.100.0.0/16", "2001:db8::/32").
+ // +required
+ Prefix IPPrefix `json:"prefix"`
+
+ // VRF selects the VRF routing table to check.
+ // If omitted, the default/global routing table is checked.
+ // +optional
+ VRF *VRFSource `json:"vrf,omitempty"`
+}
+
+// VTEPPeerConnectivityProbe asserts that expected remote VTEP peers are present and operationally up.
+type VTEPPeerConnectivityProbe struct {
+ // ExpectedPeers lists remote VTEP IP addresses that must be present and up on the device.
+ // +required
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=256
+ ExpectedPeers []string `json:"expectedPeers"`
+}
+
+// ProbeStatus defines the observed state of Probe.
+type ProbeStatus struct {
+ // LastRunTime is the timestamp of the most recent probe execution,
+ // regardless of outcome.
+ // +optional
+ LastRunTime *metav1.Time `json:"lastRunTime,omitempty"`
+
+ // NextRunTime is the next time at which the controller intends to
+ // execute the probe. Only set when Schedule is configured.
+ // +optional
+ NextRunTime *metav1.Time `json:"nextRunTime,omitempty"`
+
+ // Ping contains the result of the last Ping probe execution.
+ // Only set when the probe type is Ping.
+ // +optional
+ Ping *PingProbeResult `json:"ping,omitempty"`
+
+ // Conditions represent the current state of the Probe resource.
+ // The Ready condition indicates whether the probe assertion passed (True) or failed (False).
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// PingProbeResult contains the result of a Ping probe execution.
+type PingProbeResult struct {
+ // Sent is the number of ICMP echo requests sent.
+ // +optional
+ Sent int32 `json:"sent,omitempty"`
+
+ // Received is the number of ICMP echo replies received.
+ // +optional
+ Received int32 `json:"received,omitempty"`
+
+ // MinTime is the minimum round-trip time.
+ // +optional
+ MinTime *metav1.Duration `json:"minTime,omitempty"`
+
+ // AvgTime is the average round-trip time.
+ // +optional
+ AvgTime *metav1.Duration `json:"avgTime,omitempty"`
+
+ // MaxTime is the maximum round-trip time.
+ // +optional
+ MaxTime *metav1.Duration `json:"maxTime,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:path=probes
+// +kubebuilder:resource:singular=probe
+// +kubebuilder:resource:shortName=networkprobe;netprobe
+// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name`
+// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
+// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
+// +kubebuilder:printcolumn:name="Last Run",type=date,JSONPath=`.status.lastRunTime`,priority=1
+// +kubebuilder:printcolumn:name="Next Run",type=string,JSONPath=`.status.nextRunTime`,priority=1
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// Probe is the Schema for the probes API.
+type Probe struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Specification of the desired state of the resource.
+ // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ // +required
+ Spec ProbeSpec `json:"spec"`
+
+ // Status of the resource. This is set and updated automatically.
+ // Read-only.
+ // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ // +optional
+ Status ProbeStatus `json:"status,omitzero"`
+}
+
+// GetInterfaceReferences returns the names of all Interface resources referenced by this Probe.
+func (c *Probe) GetInterfaceReferences() []string {
+ if c.Spec.Type == ProbeTypePing && c.Spec.Ping != nil &&
+ c.Spec.Ping.SourceInterface != nil && c.Spec.Ping.SourceInterface.InterfaceRef != nil {
+ return []string{c.Spec.Ping.SourceInterface.InterfaceRef.Name}
+ }
+ return nil
+}
+
+// GetVLANReferences returns the names of all VLAN resources referenced by this Probe.
+func (c *Probe) GetVLANReferences() []string {
+ if c.Spec.Type == ProbeTypeMACTableEntry && c.Spec.MACTableEntry != nil &&
+ c.Spec.MACTableEntry.VLAN != nil && c.Spec.MACTableEntry.VLAN.VLANRef != nil {
+ return []string{c.Spec.MACTableEntry.VLAN.VLANRef.Name}
+ }
+ return nil
+}
+
+// GetVRFReferences returns the names of all VRF resources referenced by this Probe.
+func (c *Probe) GetVRFReferences() []string {
+ var refs []string
+ switch c.Spec.Type {
+ case ProbeTypePing:
+ if c.Spec.Ping != nil && c.Spec.Ping.VRF != nil && c.Spec.Ping.VRF.VRFRef != nil {
+ refs = append(refs, c.Spec.Ping.VRF.VRFRef.Name)
+ }
+ case ProbeTypeRoutePresence:
+ if c.Spec.RoutePresence != nil && c.Spec.RoutePresence.VRF != nil && c.Spec.RoutePresence.VRF.VRFRef != nil {
+ refs = append(refs, c.Spec.RoutePresence.VRF.VRFRef.Name)
+ }
+ case ProbeTypeMACTableEntry, ProbeTypeVTEPPeerConnectivity:
+ }
+ return refs
+}
+
+// GetConditions implements conditions.Getter.
+func (c *Probe) GetConditions() []metav1.Condition {
+ return c.Status.Conditions
+}
+
+// SetConditions implements conditions.Setter.
+func (c *Probe) SetConditions(conditions []metav1.Condition) {
+ c.Status.Conditions = conditions
+}
+
+// +kubebuilder:object:root=true
+
+// ProbeList contains a list of Probe.
+type ProbeList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitzero"`
+ Items []Probe `json:"items"`
+}
+
+var (
+ ProbeListDependencies []schema.GroupVersionKind
+ probeListDependenciesMu sync.Mutex
+)
+
+func RegisterProbeListDependency(gvk schema.GroupVersionKind) {
+ probeListDependenciesMu.Lock()
+ defer probeListDependenciesMu.Unlock()
+ ProbeListDependencies = append(ProbeListDependencies, gvk)
+}
+
+func init() {
+ SchemeBuilder.Register(func(s *runtime.Scheme) error {
+ s.AddKnownTypes(GroupVersion, &Probe{}, &ProbeList{})
+ return nil
+ })
+}
diff --git a/api/core/v1alpha1/ref_types.go b/api/core/v1alpha1/ref_types.go
index 28cffa408..0a61bcc26 100644
--- a/api/core/v1alpha1/ref_types.go
+++ b/api/core/v1alpha1/ref_types.go
@@ -118,3 +118,54 @@ type ConfigMapKeySelector struct {
// +kubebuilder:validation:MaxLength=253
Key string `json:"key"`
}
+
+// InterfaceSource identifies a interface either by literal name or by reference to a managed Interface resource.
+// Exactly one of Name or InterfaceRef must be specified.
+// +kubebuilder:validation:XValidation:rule="(has(self.name) && !has(self.interfaceRef)) || (!has(self.name) && has(self.interfaceRef))",message="exactly one of name or interfaceRef must be specified"
+type InterfaceSource struct {
+ // Name is the literal interface name on the device (e.g., "mgmt0", "Loopback0").
+ // Use this for interfaces that are not managed as Interface resources.
+ // +optional
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=63
+ Name string `json:"name,omitempty"`
+
+ // InterfaceRef references a managed Interface resource in the same namespace.
+ // The controller resolves the device interface name from this resource.
+ // +optional
+ InterfaceRef *LocalObjectReference `json:"interfaceRef,omitempty"`
+}
+
+// VLANSource identifies a VLAN either by literal ID or by reference to a managed VLAN resource.
+// Exactly one of ID or VLANRef must be specified.
+// +kubebuilder:validation:XValidation:rule="(has(self.id) && !has(self.vlanRef)) || (!has(self.id) && has(self.vlanRef))",message="exactly one of id or vlanRef must be specified"
+type VLANSource struct {
+ // ID is the literal VLAN ID on the device (1-4094).
+ // Use this for VLANs that are not managed as VLAN resources.
+ // +optional
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=4094
+ ID *int16 `json:"id,omitempty"`
+
+ // VLANRef references a managed VLAN resource in the same namespace.
+ // The controller resolves the VLAN ID from this resource.
+ // +optional
+ VLANRef *LocalObjectReference `json:"vlanRef,omitempty"`
+}
+
+// VRFSource identifies a VRF/NetworkIntance either by literal name or by reference to a managed VRF resource.
+// Exactly one of Name or VRFRef must be specified.
+// +kubebuilder:validation:XValidation:rule="(has(self.name) && !has(self.vrfRef)) || (!has(self.name) && has(self.vrfRef))",message="exactly one of name or vrfRef must be specified"
+type VRFSource struct {
+ // Name is the literal VRF name on the device (e.g., "management", "default").
+ // Use this for VRFs that are not managed as VRF resources.
+ // +optional
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=63
+ Name string `json:"name,omitempty"`
+
+ // VRFRef references a managed VRF resource in the same namespace.
+ // The controller resolves the device VRF name from this resource.
+ // +optional
+ VRFRef *LocalObjectReference `json:"vrfRef,omitempty"`
+}
diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go
index 5e992190b..1ab6b9e18 100644
--- a/api/core/v1alpha1/zz_generated.deepcopy.go
+++ b/api/core/v1alpha1/zz_generated.deepcopy.go
@@ -2522,6 +2522,26 @@ func (in *InterfaceList) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InterfaceSource) DeepCopyInto(out *InterfaceSource) {
+ *out = *in
+ if in.InterfaceRef != nil {
+ in, out := &in.InterfaceRef, &out.InterfaceRef
+ *out = new(LocalObjectReference)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceSource.
+func (in *InterfaceSource) DeepCopy() *InterfaceSource {
+ if in == nil {
+ return nil
+ }
+ out := new(InterfaceSource)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InterfaceSpec) DeepCopyInto(out *InterfaceSpec) {
*out = *in
@@ -2816,6 +2836,26 @@ func (in *LogServer) DeepCopy() *LogServer {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MACTableEntryProbe) DeepCopyInto(out *MACTableEntryProbe) {
+ *out = *in
+ if in.VLAN != nil {
+ in, out := &in.VLAN, &out.VLAN
+ *out = new(VLANSource)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MACTableEntryProbe.
+func (in *MACTableEntryProbe) DeepCopy() *MACTableEntryProbe {
+ if in == nil {
+ return nil
+ }
+ out := new(MACTableEntryProbe)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ManagementAccess) DeepCopyInto(out *ManagementAccess) {
*out = *in
@@ -3557,6 +3597,77 @@ func (in *PasswordSource) DeepCopy() *PasswordSource {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PingProbe) DeepCopyInto(out *PingProbe) {
+ *out = *in
+ in.Address.DeepCopyInto(&out.Address)
+ if in.SourceInterface != nil {
+ in, out := &in.SourceInterface, &out.SourceInterface
+ *out = new(InterfaceSource)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.VRF != nil {
+ in, out := &in.VRF, &out.VRF
+ *out = new(VRFSource)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.Count != nil {
+ in, out := &in.Count, &out.Count
+ *out = new(int32)
+ **out = **in
+ }
+ if in.PacketSize != nil {
+ in, out := &in.PacketSize, &out.PacketSize
+ *out = new(int32)
+ **out = **in
+ }
+ if in.Timeout != nil {
+ in, out := &in.Timeout, &out.Timeout
+ *out = new(v1.Duration)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PingProbe.
+func (in *PingProbe) DeepCopy() *PingProbe {
+ if in == nil {
+ return nil
+ }
+ out := new(PingProbe)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PingProbeResult) DeepCopyInto(out *PingProbeResult) {
+ *out = *in
+ if in.MinTime != nil {
+ in, out := &in.MinTime, &out.MinTime
+ *out = new(v1.Duration)
+ **out = **in
+ }
+ if in.AvgTime != nil {
+ in, out := &in.AvgTime, &out.AvgTime
+ *out = new(v1.Duration)
+ **out = **in
+ }
+ if in.MaxTime != nil {
+ in, out := &in.MaxTime, &out.MaxTime
+ *out = new(v1.Duration)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PingProbeResult.
+func (in *PingProbeResult) DeepCopy() *PingProbeResult {
+ if in == nil {
+ return nil
+ }
+ out := new(PingProbeResult)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PolicyActions) DeepCopyInto(out *PolicyActions) {
*out = *in
@@ -3764,6 +3875,141 @@ func (in *PrefixSetStatus) DeepCopy() *PrefixSetStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Probe) DeepCopyInto(out *Probe) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Probe.
+func (in *Probe) DeepCopy() *Probe {
+ if in == nil {
+ return nil
+ }
+ out := new(Probe)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Probe) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProbeList) DeepCopyInto(out *ProbeList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]Probe, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeList.
+func (in *ProbeList) DeepCopy() *ProbeList {
+ if in == nil {
+ return nil
+ }
+ out := new(ProbeList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ProbeList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProbeSpec) DeepCopyInto(out *ProbeSpec) {
+ *out = *in
+ out.DeviceRef = in.DeviceRef
+ if in.ProviderConfigRef != nil {
+ in, out := &in.ProviderConfigRef, &out.ProviderConfigRef
+ *out = new(TypedLocalObjectReference)
+ **out = **in
+ }
+ if in.Ping != nil {
+ in, out := &in.Ping, &out.Ping
+ *out = new(PingProbe)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.MACTableEntry != nil {
+ in, out := &in.MACTableEntry, &out.MACTableEntry
+ *out = new(MACTableEntryProbe)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.RoutePresence != nil {
+ in, out := &in.RoutePresence, &out.RoutePresence
+ *out = new(RoutePresenceProbe)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.VTEPPeerConnectivity != nil {
+ in, out := &in.VTEPPeerConnectivity, &out.VTEPPeerConnectivity
+ *out = new(VTEPPeerConnectivityProbe)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeSpec.
+func (in *ProbeSpec) DeepCopy() *ProbeSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(ProbeSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProbeStatus) DeepCopyInto(out *ProbeStatus) {
+ *out = *in
+ if in.LastRunTime != nil {
+ in, out := &in.LastRunTime, &out.LastRunTime
+ *out = (*in).DeepCopy()
+ }
+ if in.NextRunTime != nil {
+ in, out := &in.NextRunTime, &out.NextRunTime
+ *out = (*in).DeepCopy()
+ }
+ if in.Ping != nil {
+ in, out := &in.Ping, &out.Ping
+ *out = new(PingProbeResult)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeStatus.
+func (in *ProbeStatus) DeepCopy() *ProbeStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(ProbeStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Provisioning) DeepCopyInto(out *Provisioning) {
*out = *in
@@ -3826,6 +4072,27 @@ func (in *RendezvousPoint) DeepCopy() *RendezvousPoint {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RoutePresenceProbe) DeepCopyInto(out *RoutePresenceProbe) {
+ *out = *in
+ in.Prefix.DeepCopyInto(&out.Prefix)
+ if in.VRF != nil {
+ in, out := &in.VRF, &out.VRF
+ *out = new(VRFSource)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoutePresenceProbe.
+func (in *RoutePresenceProbe) DeepCopy() *RoutePresenceProbe {
+ if in == nil {
+ return nil
+ }
+ out := new(RoutePresenceProbe)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RouteTarget) DeepCopyInto(out *RouteTarget) {
*out = *in
@@ -4668,6 +4935,31 @@ func (in *VLANList) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VLANSource) DeepCopyInto(out *VLANSource) {
+ *out = *in
+ if in.ID != nil {
+ in, out := &in.ID, &out.ID
+ *out = new(int16)
+ **out = **in
+ }
+ if in.VLANRef != nil {
+ in, out := &in.VLANRef, &out.VLANRef
+ *out = new(LocalObjectReference)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLANSource.
+func (in *VLANSource) DeepCopy() *VLANSource {
+ if in == nil {
+ return nil
+ }
+ out := new(VLANSource)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VLANSpec) DeepCopyInto(out *VLANSpec) {
*out = *in
@@ -4780,6 +5072,26 @@ func (in *VRFList) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VRFSource) DeepCopyInto(out *VRFSource) {
+ *out = *in
+ if in.VRFRef != nil {
+ in, out := &in.VRFRef, &out.VRFRef
+ *out = new(LocalObjectReference)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VRFSource.
+func (in *VRFSource) DeepCopy() *VRFSource {
+ if in == nil {
+ return nil
+ }
+ out := new(VRFSource)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VRFSpec) DeepCopyInto(out *VRFSpec) {
*out = *in
@@ -4829,3 +5141,23 @@ func (in *VRFStatus) DeepCopy() *VRFStatus {
in.DeepCopyInto(out)
return out
}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VTEPPeerConnectivityProbe) DeepCopyInto(out *VTEPPeerConnectivityProbe) {
+ *out = *in
+ if in.ExpectedPeers != nil {
+ in, out := &in.ExpectedPeers, &out.ExpectedPeers
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VTEPPeerConnectivityProbe.
+func (in *VTEPPeerConnectivityProbe) DeepCopy() *VTEPPeerConnectivityProbe {
+ if in == nil {
+ return nil
+ }
+ out := new(VTEPPeerConnectivityProbe)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/charts/network-operator/templates/crd/probes.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/probes.networking.metal.ironcore.dev.yaml
new file mode 100644
index 000000000..97a509041
--- /dev/null
+++ b/charts/network-operator/templates/crd/probes.networking.metal.ironcore.dev.yaml
@@ -0,0 +1,476 @@
+{{- if .Values.crd.enabled }}
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ {{- if .Values.crd.keep }}
+ "helm.sh/resource-policy": keep
+ {{- end }}
+ controller-gen.kubebuilder.io/version: v0.21.0
+ name: probes.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: Probe
+ listKind: ProbeList
+ plural: probes
+ shortNames:
+ - networkprobe
+ - netprobe
+ singular: probe
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.type
+ name: Type
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.lastRunTime
+ name: Last Run
+ priority: 1
+ type: date
+ - jsonPath: .status.nextRunTime
+ name: Next Run
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: Probe is the Schema for the probes API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ Specification of the desired state of the resource.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ deviceRef:
+ description: |-
+ DeviceRef is a reference to the Device this probe targets.
+ The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ macTableEntry:
+ description: |-
+ MACTableEntry configures a MAC address table lookup probe.
+ Required when type is MACTableEntry, must be omitted otherwise.
+ properties:
+ macAddress:
+ description: MACAddress is the MAC address to look for in the
+ device's MAC table.
+ pattern: ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$
+ type: string
+ vlan:
+ description: VLAN constrains the lookup to a specific VLAN.
+ properties:
+ id:
+ description: |-
+ ID is the literal VLAN ID on the device (1-4094).
+ Use this for VLANs that are not managed as VLAN resources.
+ maximum: 4094
+ minimum: 1
+ type: integer
+ vlanRef:
+ description: |-
+ VLANRef references a managed VLAN resource in the same namespace.
+ The controller resolves the VLAN ID from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of id or vlanRef must be specified
+ rule: (has(self.id) && !has(self.vlanRef)) || (!has(self.id)
+ && has(self.vlanRef))
+ required:
+ - macAddress
+ type: object
+ ping:
+ description: |-
+ Ping configures an ICMP echo probe.
+ Required when type is Ping, must be omitted otherwise.
+ properties:
+ address:
+ description: Address is the target IPv4 or IPv6 address to ping.
+ format: ip
+ type: string
+ count:
+ default: 3
+ description: Count is the number of ICMP echo requests to send.
+ format: int32
+ maximum: 100
+ minimum: 1
+ type: integer
+ packetSize:
+ description: |-
+ PacketSize is the ICMP payload size in bytes.
+ Useful for detecting MTU issues in VXLAN overlays.
+ format: int32
+ maximum: 65507
+ minimum: 1
+ type: integer
+ sourceInterface:
+ description: |-
+ SourceInterface selects the source interface for the ping.
+ The provider uses an address on this interface with the same IP family as Address.
+ If omitted, the device selects the source interface automatically.
+ properties:
+ interfaceRef:
+ description: |-
+ InterfaceRef references a managed Interface resource in the same namespace.
+ The controller resolves the device interface name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ name:
+ description: |-
+ Name is the literal interface name on the device (e.g., "mgmt0", "Loopback0").
+ Use this for interfaces that are not managed as Interface resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or interfaceRef must be specified
+ rule: (has(self.name) && !has(self.interfaceRef)) || (!has(self.name)
+ && has(self.interfaceRef))
+ timeout:
+ description: Timeout is the maximum time to wait for a reply per
+ echo request.
+ type: string
+ vrf:
+ description: |-
+ VRF selects the VRF context in which to execute the ping.
+ If omitted, the ping is executed in the default/global routing table.
+ properties:
+ name:
+ description: |-
+ Name is the literal VRF name on the device (e.g., "management", "default").
+ Use this for VRFs that are not managed as VRF resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ vrfRef:
+ description: |-
+ VRFRef references a managed VRF resource in the same namespace.
+ The controller resolves the device VRF name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or vrfRef must be specified
+ rule: (has(self.name) && !has(self.vrfRef)) || (!has(self.name)
+ && has(self.vrfRef))
+ required:
+ - address
+ type: object
+ providerConfigRef:
+ description: |-
+ ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this probe.
+ This reference is used to link the Probe to its provider-specific configuration.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ routePresence:
+ description: |-
+ RoutePresence configures a routing table prefix lookup probe.
+ Required when type is RoutePresence, must be omitted otherwise.
+ properties:
+ prefix:
+ description: Prefix is the IP prefix to check for (e.g., "10.100.0.0/16",
+ "2001:db8::/32").
+ format: cidr
+ type: string
+ vrf:
+ description: |-
+ VRF selects the VRF routing table to check.
+ If omitted, the default/global routing table is checked.
+ properties:
+ name:
+ description: |-
+ Name is the literal VRF name on the device (e.g., "management", "default").
+ Use this for VRFs that are not managed as VRF resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ vrfRef:
+ description: |-
+ VRFRef references a managed VRF resource in the same namespace.
+ The controller resolves the device VRF name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or vrfRef must be specified
+ rule: (has(self.name) && !has(self.vrfRef)) || (!has(self.name)
+ && has(self.vrfRef))
+ required:
+ - prefix
+ type: object
+ schedule:
+ description: |-
+ Schedule is an optional cron expression (e.g., "*/5 * * * *").
+ If omitted, the controller performs a one-shot probe execution only once
+ for the Probe resource; it does not re-execute on subsequent reconciliations.
+ If set, the controller executes the probe periodically according to the schedule.
+ type: string
+ type:
+ description: Type selects which probe assertion to execute.
+ enum:
+ - Ping
+ - MACTableEntry
+ - RoutePresence
+ - VTEPPeerConnectivity
+ type: string
+ vtepPeerConnectivity:
+ description: |-
+ VTEPPeerConnectivity configures a VTEP peer connectivity probe.
+ Required when type is VTEPPeerConnectivity, must be omitted otherwise.
+ properties:
+ expectedPeers:
+ description: ExpectedPeers lists remote VTEP IP addresses that
+ must be present and up on the device.
+ items:
+ type: string
+ maxItems: 256
+ minItems: 1
+ type: array
+ required:
+ - expectedPeers
+ type: object
+ required:
+ - deviceRef
+ - type
+ type: object
+ x-kubernetes-validations:
+ - message: ping must be specified when type is Ping
+ rule: self.type != 'Ping' || has(self.ping)
+ - message: ping must be omitted when type is not Ping
+ rule: self.type == 'Ping' || !has(self.ping)
+ - message: macTableEntry must be specified when type is MACTableEntry
+ rule: self.type != 'MACTableEntry' || has(self.macTableEntry)
+ - message: macTableEntry must be omitted when type is not MACTableEntry
+ rule: self.type == 'MACTableEntry' || !has(self.macTableEntry)
+ - message: routePresence must be specified when type is RoutePresence
+ rule: self.type != 'RoutePresence' || has(self.routePresence)
+ - message: routePresence must be omitted when type is not RoutePresence
+ rule: self.type == 'RoutePresence' || !has(self.routePresence)
+ - message: vtepPeerConnectivity must be specified when type is VTEPPeerConnectivity
+ rule: self.type != 'VTEPPeerConnectivity' || has(self.vtepPeerConnectivity)
+ - message: vtepPeerConnectivity must be omitted when type is not VTEPPeerConnectivity
+ rule: self.type == 'VTEPPeerConnectivity' || !has(self.vtepPeerConnectivity)
+ status:
+ description: |-
+ Status of the resource. This is set and updated automatically.
+ Read-only.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ conditions:
+ description: |-
+ Conditions represent the current state of the Probe resource.
+ The Ready condition indicates whether the probe assertion passed (True) or failed (False).
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ lastRunTime:
+ description: |-
+ LastRunTime is the timestamp of the most recent probe execution,
+ regardless of outcome.
+ format: date-time
+ type: string
+ nextRunTime:
+ description: |-
+ NextRunTime is the next time at which the controller intends to
+ execute the probe. Only set when Schedule is configured.
+ format: date-time
+ type: string
+ ping:
+ description: |-
+ Ping contains the result of the last Ping probe execution.
+ Only set when the probe type is Ping.
+ properties:
+ avgTime:
+ description: AvgTime is the average round-trip time.
+ type: string
+ maxTime:
+ description: MaxTime is the maximum round-trip time.
+ type: string
+ minTime:
+ description: MinTime is the minimum round-trip time.
+ type: string
+ received:
+ description: Received is the number of ICMP echo replies received.
+ format: int32
+ type: integer
+ sent:
+ description: Sent is the number of ICMP echo requests sent.
+ format: int32
+ type: integer
+ type: object
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 9e70a2667..79729cf5f 100644
--- a/charts/network-operator/templates/rbac/manager-role.yaml
+++ b/charts/network-operator/templates/rbac/manager-role.yaml
@@ -95,6 +95,7 @@ rules:
- ospf
- pim
- prefixsets
+ - probes
- routingpolicies
- snmp
- syslogs
@@ -164,6 +165,7 @@ rules:
- ospf/status
- pim/status
- prefixsets/status
+ - probes/status
- routingpolicies/status
- snmp/status
- syslogs/status
diff --git a/charts/network-operator/templates/rbac/probe-admin-role.yaml b/charts/network-operator/templates/rbac/probe-admin-role.yaml
new file mode 100644
index 000000000..91752e389
--- /dev/null
+++ b/charts/network-operator/templates/rbac/probe-admin-role.yaml
@@ -0,0 +1,31 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "probe-admin-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/probe-editor-role.yaml b/charts/network-operator/templates/rbac/probe-editor-role.yaml
new file mode 100644
index 000000000..9c0f77ecb
--- /dev/null
+++ b/charts/network-operator/templates/rbac/probe-editor-role.yaml
@@ -0,0 +1,37 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "probe-editor-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/probe-viewer-role.yaml b/charts/network-operator/templates/rbac/probe-viewer-role.yaml
new file mode 100644
index 000000000..4ee252d8b
--- /dev/null
+++ b/charts/network-operator/templates/rbac/probe-viewer-role.yaml
@@ -0,0 +1,33 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "probe-viewer-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
+{{- end }}
diff --git a/cmd/main.go b/cmd/main.go
index efeff4163..732a4b4c7 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -839,6 +839,19 @@ func main() { //nolint:gocyclo
setupLog.Error(err, "Failed to create controller", "controller", "Fabric")
os.Exit(1)
}
+
+ if err := (&corecontroller.ProbeReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ Recorder: mgr.GetEventRecorder("probe-controller"),
+ WatchFilterValue: watchFilterValue,
+ Provider: prov,
+ Locker: locker,
+ }).SetupWithManager(ctx, mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "Probe")
+ os.Exit(1)
+ }
+
// +kubebuilder:scaffold:builder
if metricsCertWatcher != nil {
diff --git a/config/crd/bases/networking.metal.ironcore.dev_probes.yaml b/config/crd/bases/networking.metal.ironcore.dev_probes.yaml
new file mode 100644
index 000000000..01c07b309
--- /dev/null
+++ b/config/crd/bases/networking.metal.ironcore.dev_probes.yaml
@@ -0,0 +1,472 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.21.0
+ name: probes.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: Probe
+ listKind: ProbeList
+ plural: probes
+ shortNames:
+ - networkprobe
+ - netprobe
+ singular: probe
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.type
+ name: Type
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.lastRunTime
+ name: Last Run
+ priority: 1
+ type: date
+ - jsonPath: .status.nextRunTime
+ name: Next Run
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: Probe is the Schema for the probes API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ Specification of the desired state of the resource.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ deviceRef:
+ description: |-
+ DeviceRef is a reference to the Device this probe targets.
+ The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ macTableEntry:
+ description: |-
+ MACTableEntry configures a MAC address table lookup probe.
+ Required when type is MACTableEntry, must be omitted otherwise.
+ properties:
+ macAddress:
+ description: MACAddress is the MAC address to look for in the
+ device's MAC table.
+ pattern: ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$
+ type: string
+ vlan:
+ description: VLAN constrains the lookup to a specific VLAN.
+ properties:
+ id:
+ description: |-
+ ID is the literal VLAN ID on the device (1-4094).
+ Use this for VLANs that are not managed as VLAN resources.
+ maximum: 4094
+ minimum: 1
+ type: integer
+ vlanRef:
+ description: |-
+ VLANRef references a managed VLAN resource in the same namespace.
+ The controller resolves the VLAN ID from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of id or vlanRef must be specified
+ rule: (has(self.id) && !has(self.vlanRef)) || (!has(self.id)
+ && has(self.vlanRef))
+ required:
+ - macAddress
+ type: object
+ ping:
+ description: |-
+ Ping configures an ICMP echo probe.
+ Required when type is Ping, must be omitted otherwise.
+ properties:
+ address:
+ description: Address is the target IPv4 or IPv6 address to ping.
+ format: ip
+ type: string
+ count:
+ default: 3
+ description: Count is the number of ICMP echo requests to send.
+ format: int32
+ maximum: 100
+ minimum: 1
+ type: integer
+ packetSize:
+ description: |-
+ PacketSize is the ICMP payload size in bytes.
+ Useful for detecting MTU issues in VXLAN overlays.
+ format: int32
+ maximum: 65507
+ minimum: 1
+ type: integer
+ sourceInterface:
+ description: |-
+ SourceInterface selects the source interface for the ping.
+ The provider uses an address on this interface with the same IP family as Address.
+ If omitted, the device selects the source interface automatically.
+ properties:
+ interfaceRef:
+ description: |-
+ InterfaceRef references a managed Interface resource in the same namespace.
+ The controller resolves the device interface name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ name:
+ description: |-
+ Name is the literal interface name on the device (e.g., "mgmt0", "Loopback0").
+ Use this for interfaces that are not managed as Interface resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or interfaceRef must be specified
+ rule: (has(self.name) && !has(self.interfaceRef)) || (!has(self.name)
+ && has(self.interfaceRef))
+ timeout:
+ description: Timeout is the maximum time to wait for a reply per
+ echo request.
+ type: string
+ vrf:
+ description: |-
+ VRF selects the VRF context in which to execute the ping.
+ If omitted, the ping is executed in the default/global routing table.
+ properties:
+ name:
+ description: |-
+ Name is the literal VRF name on the device (e.g., "management", "default").
+ Use this for VRFs that are not managed as VRF resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ vrfRef:
+ description: |-
+ VRFRef references a managed VRF resource in the same namespace.
+ The controller resolves the device VRF name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or vrfRef must be specified
+ rule: (has(self.name) && !has(self.vrfRef)) || (!has(self.name)
+ && has(self.vrfRef))
+ required:
+ - address
+ type: object
+ providerConfigRef:
+ description: |-
+ ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this probe.
+ This reference is used to link the Probe to its provider-specific configuration.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ routePresence:
+ description: |-
+ RoutePresence configures a routing table prefix lookup probe.
+ Required when type is RoutePresence, must be omitted otherwise.
+ properties:
+ prefix:
+ description: Prefix is the IP prefix to check for (e.g., "10.100.0.0/16",
+ "2001:db8::/32").
+ format: cidr
+ type: string
+ vrf:
+ description: |-
+ VRF selects the VRF routing table to check.
+ If omitted, the default/global routing table is checked.
+ properties:
+ name:
+ description: |-
+ Name is the literal VRF name on the device (e.g., "management", "default").
+ Use this for VRFs that are not managed as VRF resources.
+ maxLength: 63
+ minLength: 1
+ type: string
+ vrfRef:
+ description: |-
+ VRFRef references a managed VRF resource in the same namespace.
+ The controller resolves the device VRF name from this resource.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one of name or vrfRef must be specified
+ rule: (has(self.name) && !has(self.vrfRef)) || (!has(self.name)
+ && has(self.vrfRef))
+ required:
+ - prefix
+ type: object
+ schedule:
+ description: |-
+ Schedule is an optional cron expression (e.g., "*/5 * * * *").
+ If omitted, the controller performs a one-shot probe execution only once
+ for the Probe resource; it does not re-execute on subsequent reconciliations.
+ If set, the controller executes the probe periodically according to the schedule.
+ type: string
+ type:
+ description: Type selects which probe assertion to execute.
+ enum:
+ - Ping
+ - MACTableEntry
+ - RoutePresence
+ - VTEPPeerConnectivity
+ type: string
+ vtepPeerConnectivity:
+ description: |-
+ VTEPPeerConnectivity configures a VTEP peer connectivity probe.
+ Required when type is VTEPPeerConnectivity, must be omitted otherwise.
+ properties:
+ expectedPeers:
+ description: ExpectedPeers lists remote VTEP IP addresses that
+ must be present and up on the device.
+ items:
+ type: string
+ maxItems: 256
+ minItems: 1
+ type: array
+ required:
+ - expectedPeers
+ type: object
+ required:
+ - deviceRef
+ - type
+ type: object
+ x-kubernetes-validations:
+ - message: ping must be specified when type is Ping
+ rule: self.type != 'Ping' || has(self.ping)
+ - message: ping must be omitted when type is not Ping
+ rule: self.type == 'Ping' || !has(self.ping)
+ - message: macTableEntry must be specified when type is MACTableEntry
+ rule: self.type != 'MACTableEntry' || has(self.macTableEntry)
+ - message: macTableEntry must be omitted when type is not MACTableEntry
+ rule: self.type == 'MACTableEntry' || !has(self.macTableEntry)
+ - message: routePresence must be specified when type is RoutePresence
+ rule: self.type != 'RoutePresence' || has(self.routePresence)
+ - message: routePresence must be omitted when type is not RoutePresence
+ rule: self.type == 'RoutePresence' || !has(self.routePresence)
+ - message: vtepPeerConnectivity must be specified when type is VTEPPeerConnectivity
+ rule: self.type != 'VTEPPeerConnectivity' || has(self.vtepPeerConnectivity)
+ - message: vtepPeerConnectivity must be omitted when type is not VTEPPeerConnectivity
+ rule: self.type == 'VTEPPeerConnectivity' || !has(self.vtepPeerConnectivity)
+ status:
+ description: |-
+ Status of the resource. This is set and updated automatically.
+ Read-only.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ conditions:
+ description: |-
+ Conditions represent the current state of the Probe resource.
+ The Ready condition indicates whether the probe assertion passed (True) or failed (False).
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ lastRunTime:
+ description: |-
+ LastRunTime is the timestamp of the most recent probe execution,
+ regardless of outcome.
+ format: date-time
+ type: string
+ nextRunTime:
+ description: |-
+ NextRunTime is the next time at which the controller intends to
+ execute the probe. Only set when Schedule is configured.
+ format: date-time
+ type: string
+ ping:
+ description: |-
+ Ping contains the result of the last Ping probe execution.
+ Only set when the probe type is Ping.
+ properties:
+ avgTime:
+ description: AvgTime is the average round-trip time.
+ type: string
+ maxTime:
+ description: MaxTime is the maximum round-trip time.
+ type: string
+ minTime:
+ description: MinTime is the minimum round-trip time.
+ type: string
+ received:
+ description: Received is the number of ICMP echo replies received.
+ format: int32
+ type: integer
+ sent:
+ description: Sent is the number of ICMP echo requests sent.
+ format: int32
+ type: integer
+ type: object
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 3737c26a0..0281b3f4e 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -29,6 +29,7 @@ resources:
- bases/networking.metal.ironcore.dev_configbackups.yaml
- bases/networking.metal.ironcore.dev_ethernetsegments.yaml
- bases/networking.metal.ironcore.dev_aaa.yaml
+- bases/networking.metal.ironcore.dev_probes.yaml
- bases/pool.networking.metal.ironcore.dev_indexpools.yaml
- bases/pool.networking.metal.ironcore.dev_ipaddresspools.yaml
- bases/pool.networking.metal.ironcore.dev_ipprefixpools.yaml
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index b9b75c997..c8e18ad6b 100644
--- a/config/rbac/kustomization.yaml
+++ b/config/rbac/kustomization.yaml
@@ -100,6 +100,9 @@ resources:
- vrf_admin_role.yaml
- vrf_editor_role.yaml
- vrf_viewer_role.yaml
+- probe_admin_role.yaml
+- probe_editor_role.yaml
+- probe_viewer_role.yaml
# The following RBAC configurations apply to Cisco NX specific CRDs
- cisco/nx/bordergateway_admin_role.yaml
- cisco/nx/bordergateway_editor_role.yaml
diff --git a/config/rbac/probe_admin_role.yaml b/config/rbac/probe_admin_role.yaml
new file mode 100644
index 000000000..66be719e3
--- /dev/null
+++ b/config/rbac/probe_admin_role.yaml
@@ -0,0 +1,27 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants full permissions ('*') over networking.metal.ironcore.dev.
+# This role is intended for users authorized to modify roles and bindings within the cluster,
+# enabling them to delegate specific permissions to other users or groups as needed.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: probe-admin-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
diff --git a/config/rbac/probe_editor_role.yaml b/config/rbac/probe_editor_role.yaml
new file mode 100644
index 000000000..6c75b4b32
--- /dev/null
+++ b/config/rbac/probe_editor_role.yaml
@@ -0,0 +1,33 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev.
+# This role is intended for users who need to manage these resources
+# but should not control RBAC or manage permissions for others.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: probe-editor-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
diff --git a/config/rbac/probe_viewer_role.yaml b/config/rbac/probe_viewer_role.yaml
new file mode 100644
index 000000000..734298ca2
--- /dev/null
+++ b/config/rbac/probe_viewer_role.yaml
@@ -0,0 +1,29 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants read-only access to networking.metal.ironcore.dev resources.
+# This role is intended for users who need visibility into these resources
+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: probe-viewer-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - probes/status
+ verbs:
+ - get
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 4290e7f51..66cdebb60 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -89,6 +89,7 @@ rules:
- ospf
- pim
- prefixsets
+ - probes
- routingpolicies
- snmp
- syslogs
@@ -158,6 +159,7 @@ rules:
- ospf/status
- pim/status
- prefixsets/status
+ - probes/status
- routingpolicies/status
- snmp/status
- syslogs/status
diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml
index f61dddb3b..4fdb74fa5 100644
--- a/config/samples/kustomization.yaml
+++ b/config/samples/kustomization.yaml
@@ -35,6 +35,7 @@ resources:
- v1alpha1_ipprefix.yaml
- v1alpha1_claim.yaml
- v1alpha1_fabric.yaml
+- v1alpha1_probe.yaml
- cisco/nx/v1alpha1_bordergateway.yaml
- cisco/nx/v1alpha1_managementaccessconfig.yaml
- cisco/nx/v1alpha1_nveconfig.yaml
diff --git a/config/samples/v1alpha1_probe.yaml b/config/samples/v1alpha1_probe.yaml
new file mode 100644
index 000000000..52c080070
--- /dev/null
+++ b/config/samples/v1alpha1_probe.yaml
@@ -0,0 +1,73 @@
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: Probe
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ networking.metal.ironcore.dev/device-name: leaf1
+ name: ping-peer
+spec:
+ deviceRef:
+ name: leaf1
+ schedule: "* * * * *"
+ type: Ping
+ ping:
+ address: "10.0.0.12"
+ sourceInterface:
+ interfaceRef:
+ name: lo0
+ vrf:
+ name: default
+ count: 3
+ packetSize: 1400
+ timeout: 5s
+---
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: Probe
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ networking.metal.ironcore.dev/device-name: leaf1
+ name: mac-entry
+spec:
+ deviceRef:
+ name: leaf1
+ type: MACTableEntry
+ macTableEntry:
+ macAddress: "00:00:00:00:00:02"
+ vlan:
+ id: 10
+---
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: Probe
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ networking.metal.ironcore.dev/device-name: leaf1
+ name: route-prefix
+spec:
+ deviceRef:
+ name: leaf1
+ type: RoutePresence
+ routePresence:
+ prefix: "192.168.10.0/24"
+ vrf:
+ name: default
+---
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: Probe
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ networking.metal.ironcore.dev/device-name: leaf1
+ name: vtep-peers
+spec:
+ deviceRef:
+ name: leaf1
+ type: VTEPPeerConnectivity
+ vtepPeerConnectivity:
+ expectedPeers:
+ - "10.0.1.12"
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index 9bcbfcca4..5e3a4a5bd 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -346,6 +346,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco
- [OSPF](#ospf)
- [PIM](#pim)
- [PrefixSet](#prefixset)
+- [Probe](#probe)
- [RoutingPolicy](#routingpolicy)
- [SNMP](#snmp)
- [Syslog](#syslog)
@@ -2236,6 +2237,7 @@ _Validation:_
_Appears in:_
- [IPAddressSpec](#ipaddressspec)
+- [PingProbe](#pingprobe)
@@ -2260,6 +2262,7 @@ _Appears in:_
- [MulticastGroups](#multicastgroups)
- [PrefixEntry](#prefixentry)
- [RendezvousPoint](#rendezvouspoint)
+- [RoutePresenceProbe](#routepresenceprobe)
@@ -2431,6 +2434,24 @@ _Appears in:_
| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to the interface from which to borrow the IP address.
The referenced interface must exist and have at least one IPv4 address configured. | | Required: \{\}
|
+#### InterfaceSource
+
+
+
+InterfaceSource identifies a interface either by literal name or by reference to a managed Interface resource.
+Exactly one of Name or InterfaceRef must be specified.
+
+
+
+_Appears in:_
+- [PingProbe](#pingprobe)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name is the literal interface name on the device (e.g., "mgmt0", "Loopback0").
Use this for interfaces that are not managed as Interface resources. | | MaxLength: 63
MinLength: 1
Optional: \{\}
|
+| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef references a managed Interface resource in the same namespace.
The controller resolves the device interface name from this resource. | | Optional: \{\}
|
+
+
#### InterfaceSpec
@@ -2641,6 +2662,7 @@ _Appears in:_
- [ISISSpec](#isisspec)
- [InterconnectInterfaceReference](#interconnectinterfacereference)
- [InterfaceIPv4Unnumbered](#interfaceipv4unnumbered)
+- [InterfaceSource](#interfacesource)
- [InterfaceSpec](#interfacespec)
- [InterfaceStatus](#interfacestatus)
- [KeepAlive](#keepalive)
@@ -2657,14 +2679,17 @@ _Appears in:_
- [Peer](#peer)
- [PrefixSetMatchCondition](#prefixsetmatchcondition)
- [PrefixSetSpec](#prefixsetspec)
+- [ProbeSpec](#probespec)
- [RoutingPolicySpec](#routingpolicyspec)
- [SNMPSpec](#snmpspec)
- [SyslogSpec](#syslogspec)
- [SystemSpec](#systemspec)
- [UserSpec](#userspec)
+- [VLANSource](#vlansource)
- [VLANSpec](#vlanspec)
- [VLANStatus](#vlanstatus)
- [VPCDomainSpec](#vpcdomainspec)
+- [VRFSource](#vrfsource)
- [VRFSpec](#vrfspec)
| Field | Description | Default | Validation |
@@ -2708,6 +2733,23 @@ _Appears in:_
| `port` _integer_ | The destination port number for syslog UDP messages to
the server. The default is 514. | 514 | Optional: \{\}
|
+#### MACTableEntryProbe
+
+
+
+MACTableEntryProbe asserts that a specific MAC address exists in the device's forwarding table.
+
+
+
+_Appears in:_
+- [ProbeSpec](#probespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `macAddress` _string_ | MACAddress is the MAC address to look for in the device's MAC table. | | Pattern: `^([0-9a-fA-F]\{2\}:)\{5\}[0-9a-fA-F]\{2\}$`
Required: \{\}
|
+| `vlan` _[VLANSource](#vlansource)_ | VLAN constrains the lookup to a specific VLAN. | | Optional: \{\}
|
+
+
#### ManagementAccess
@@ -3262,6 +3304,47 @@ _Appears in:_
| `secretKeyRef` _[SecretKeySelector](#secretkeyselector)_ | Selects a key of a secret. | | Required: \{\}
|
+#### PingProbe
+
+
+
+PingProbe configures an ICMP echo probe from the device to a target address.
+
+
+
+_Appears in:_
+- [ProbeSpec](#probespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `address` _[IPAddr](#ipaddr)_ | Address is the target IPv4 or IPv6 address to ping. | | Format: ip
Type: string
Required: \{\}
|
+| `sourceInterface` _[InterfaceSource](#interfacesource)_ | SourceInterface selects the source interface for the ping.
The provider uses an address on this interface with the same IP family as Address.
If omitted, the device selects the source interface automatically. | | Optional: \{\}
|
+| `vrf` _[VRFSource](#vrfsource)_ | VRF selects the VRF context in which to execute the ping.
If omitted, the ping is executed in the default/global routing table. | | Optional: \{\}
|
+| `count` _integer_ | Count is the number of ICMP echo requests to send. | 3 | Maximum: 100
Minimum: 1
Optional: \{\}
|
+| `packetSize` _integer_ | PacketSize is the ICMP payload size in bytes.
Useful for detecting MTU issues in VXLAN overlays. | | Maximum: 65507
Minimum: 1
Optional: \{\}
|
+| `timeout` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | Timeout is the maximum time to wait for a reply per echo request. | | Optional: \{\}
|
+
+
+#### PingProbeResult
+
+
+
+PingProbeResult contains the result of a Ping probe execution.
+
+
+
+_Appears in:_
+- [ProbeStatus](#probestatus)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `sent` _integer_ | Sent is the number of ICMP echo requests sent. | | Optional: \{\}
|
+| `received` _integer_ | Received is the number of ICMP echo replies received. | | Optional: \{\}
|
+| `minTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | MinTime is the minimum round-trip time. | | Optional: \{\}
|
+| `avgTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | AvgTime is the average round-trip time. | | Optional: \{\}
|
+| `maxTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | MaxTime is the maximum round-trip time. | | Optional: \{\}
|
+
+
#### PolicyActions
@@ -3425,6 +3508,87 @@ _Appears in:_
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the PrefixSet. | | Optional: \{\}
|
+#### Probe
+
+
+
+Probe is the Schema for the probes API.
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `Probe` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[ProbeSpec](#probespec)_ | Specification of the desired state of the resource.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Required: \{\}
|
+| `status` _[ProbeStatus](#probestatus)_ | Status of the resource. This is set and updated automatically.
Read-only.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
|
+
+
+#### ProbeSpec
+
+
+
+ProbeSpec defines the desired state of Probe.
+
+
+
+_Appears in:_
+- [Probe](#probe)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceRef is a reference to the Device this probe targets.
The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
|
+| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this probe.
This reference is used to link the Probe to its provider-specific configuration. | | Optional: \{\}
|
+| `schedule` _string_ | Schedule is an optional cron expression (e.g., "*/5 * * * *").
If omitted, the controller performs a one-shot probe execution only once
for the Probe resource; it does not re-execute on subsequent reconciliations.
If set, the controller executes the probe periodically according to the schedule. | | Optional: \{\}
|
+| `type` _[ProbeType](#probetype)_ | Type selects which probe assertion to execute. | | Enum: [Ping MACTableEntry RoutePresence VTEPPeerConnectivity]
Required: \{\}
|
+| `ping` _[PingProbe](#pingprobe)_ | Ping configures an ICMP echo probe.
Required when type is Ping, must be omitted otherwise. | | Optional: \{\}
|
+| `macTableEntry` _[MACTableEntryProbe](#mactableentryprobe)_ | MACTableEntry configures a MAC address table lookup probe.
Required when type is MACTableEntry, must be omitted otherwise. | | Optional: \{\}
|
+| `routePresence` _[RoutePresenceProbe](#routepresenceprobe)_ | RoutePresence configures a routing table prefix lookup probe.
Required when type is RoutePresence, must be omitted otherwise. | | Optional: \{\}
|
+| `vtepPeerConnectivity` _[VTEPPeerConnectivityProbe](#vteppeerconnectivityprobe)_ | VTEPPeerConnectivity configures a VTEP peer connectivity probe.
Required when type is VTEPPeerConnectivity, must be omitted otherwise. | | Optional: \{\}
|
+
+
+#### ProbeStatus
+
+
+
+ProbeStatus defines the observed state of Probe.
+
+
+
+_Appears in:_
+- [Probe](#probe)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `lastRunTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | LastRunTime is the timestamp of the most recent probe execution,
regardless of outcome. | | Optional: \{\}
|
+| `nextRunTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | NextRunTime is the next time at which the controller intends to
execute the probe. Only set when Schedule is configured. | | Optional: \{\}
|
+| `ping` _[PingProbeResult](#pingproberesult)_ | Ping contains the result of the last Ping probe execution.
Only set when the probe type is Ping. | | Optional: \{\}
|
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | Conditions represent the current state of the Probe resource.
The Ready condition indicates whether the probe assertion passed (True) or failed (False). | | Optional: \{\}
|
+
+
+#### ProbeType
+
+_Underlying type:_ _string_
+
+ProbeType selects which assertion a Probe executes.
+
+_Validation:_
+- Enum: [Ping MACTableEntry RoutePresence VTEPPeerConnectivity]
+
+_Appears in:_
+- [ProbeSpec](#probespec)
+
+| Field | Description |
+| --- | --- |
+| `Ping` | ProbeTypePing sends ICMP echo requests from the device to a target address.
|
+| `MACTableEntry` | ProbeTypeMACTableEntry asserts that a specific MAC address exists in the device's MAC table.
|
+| `RoutePresence` | ProbeTypeRoutePresence asserts that an IP prefix exists in a routing table.
|
+| `VTEPPeerConnectivity` | ProbeTypeVTEPPeerConnectivity asserts that expected remote VTEP peers are present and up.
|
+
+
#### Protocol
_Underlying type:_ _string_
@@ -3540,6 +3704,23 @@ _Appears in:_
| `RejectRoute` | RejectRoute denies the route immediately.
|
+#### RoutePresenceProbe
+
+
+
+RoutePresenceProbe asserts that an IP prefix exists in the device's routing table.
+
+
+
+_Appears in:_
+- [ProbeSpec](#probespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `prefix` _[IPPrefix](#ipprefix)_ | Prefix is the IP prefix to check for (e.g., "10.100.0.0/16", "2001:db8::/32"). | | Format: cidr
Type: string
Required: \{\}
|
+| `vrf` _[VRFSource](#vrfsource)_ | VRF selects the VRF routing table to check.
If omitted, the default/global routing table is checked. | | Optional: \{\}
|
+
+
#### RouteTarget
@@ -4106,6 +4287,7 @@ _Appears in:_
- [OSPFSpec](#ospfspec)
- [PIMSpec](#pimspec)
- [PrefixSetSpec](#prefixsetspec)
+- [ProbeSpec](#probespec)
- [RoutingPolicySpec](#routingpolicyspec)
- [SNMPSpec](#snmpspec)
- [SyslogSpec](#syslogspec)
@@ -4211,6 +4393,24 @@ VLAN is the Schema for the vlans API
| `status` _[VLANStatus](#vlanstatus)_ | Status of the resource. This is set and updated automatically.
Read-only.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
|
+#### VLANSource
+
+
+
+VLANSource identifies a VLAN either by literal ID or by reference to a managed VLAN resource.
+Exactly one of ID or VLANRef must be specified.
+
+
+
+_Appears in:_
+- [MACTableEntryProbe](#mactableentryprobe)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `id` _integer_ | ID is the literal VLAN ID on the device (1-4094).
Use this for VLANs that are not managed as VLAN resources. | | Maximum: 4094
Minimum: 1
Optional: \{\}
|
+| `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VLANRef references a managed VLAN resource in the same namespace.
The controller resolves the VLAN ID from this resource. | | Optional: \{\}
|
+
+
#### VLANSpec
@@ -4268,6 +4468,25 @@ VRF is the Schema for the vrfs API
| `status` _[VRFStatus](#vrfstatus)_ | status of the resource. This is set and updated automatically.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
|
+#### VRFSource
+
+
+
+VRFSource identifies a VRF/NetworkIntance either by literal name or by reference to a managed VRF resource.
+Exactly one of Name or VRFRef must be specified.
+
+
+
+_Appears in:_
+- [PingProbe](#pingprobe)
+- [RoutePresenceProbe](#routepresenceprobe)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name is the literal VRF name on the device (e.g., "management", "default").
Use this for VRFs that are not managed as VRF resources. | | MaxLength: 63
MinLength: 1
Optional: \{\}
|
+| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VRFRef references a managed VRF resource in the same namespace.
The controller resolves the device VRF name from this resource. | | Optional: \{\}
|
+
+
#### VRFSpec
@@ -4306,6 +4525,22 @@ _Appears in:_
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the VRF. | | Optional: \{\}
|
+#### VTEPPeerConnectivityProbe
+
+
+
+VTEPPeerConnectivityProbe asserts that expected remote VTEP peers are present and operationally up.
+
+
+
+_Appears in:_
+- [ProbeSpec](#probespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `expectedPeers` _string array_ | ExpectedPeers lists remote VTEP IP addresses that must be present and up on the device. | | MaxItems: 256
MinItems: 1
Required: \{\}
|
+
+
## nx.cisco.networking.metal.ironcore.dev/v1alpha1
diff --git a/internal/controller/core/probe_controller.go b/internal/controller/core/probe_controller.go
new file mode 100644
index 000000000..c137258db
--- /dev/null
+++ b/internal/controller/core/probe_controller.go
@@ -0,0 +1,869 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/robfig/cron/v3"
+ "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ kerrors "k8s.io/apimachinery/pkg/util/errors"
+ "k8s.io/client-go/tools/events"
+ "k8s.io/klog/v2"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+ "github.com/ironcore-dev/network-operator/internal/apistatus"
+ "github.com/ironcore-dev/network-operator/internal/conditions"
+ "github.com/ironcore-dev/network-operator/internal/deviceutil"
+ "github.com/ironcore-dev/network-operator/internal/provider"
+ "github.com/ironcore-dev/network-operator/internal/resourcelock"
+)
+
+// ProbeReconciler reconciles a Probe object.
+type ProbeReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+
+ // WatchFilterValue is the label value used to filter events prior to reconciliation.
+ WatchFilterValue string
+
+ // Recorder is used to record events for the controller.
+ // More info: https://book.kubebuilder.io/reference/raising-events
+ Recorder events.EventRecorder
+
+ // Provider is the driver that will be used to execute probe assertions.
+ Provider provider.ProviderFunc
+
+ // Locker is used to synchronize operations on resources targeting the same device.
+ Locker *resourcelock.ResourceLocker
+}
+
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=probes,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=probes/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
+
+func (r *ProbeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) {
+ log := ctrl.LoggerFrom(ctx)
+ log.V(3).Info("Reconciling resource")
+
+ obj := new(v1alpha1.Probe)
+ if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
+ if apierrors.IsNotFound(err) {
+ // If the custom resource is not found then it usually means that it was deleted or not created
+ // In this way, we will stop the reconciliation
+ log.V(3).Info("Resource not found. Ignoring since object must be deleted")
+ return ctrl.Result{}, nil
+ }
+ // Error reading the object - requeue the request.
+ log.Error(err, "Failed to get resource")
+ return ctrl.Result{}, err
+ }
+
+ prov, ok := r.Provider().(provider.ProbeProvider)
+ if !ok {
+ if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.NotImplementedReason,
+ Message: "Provider does not implement provider.ProbeProvider",
+ }) {
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ if reachable := conditions.Get(device, v1alpha1.ReachableCondition); reachable != nil && reachable.Status == metav1.ConditionFalse {
+ conditions.Set(obj, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.UnreachableReason,
+ Message: "Referenced Device is not reachable",
+ })
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+
+ if err := r.Locker.AcquireLock(ctx, device.Name, "probe-controller"); err != nil {
+ if errors.Is(err, resourcelock.ErrLockAlreadyHeld) {
+ log.V(3).Info("Device is already locked, requeuing reconciliation")
+ return ctrl.Result{RequeueAfter: Jitter(time.Second), Priority: new(LockWaitPriorityDefault)}, nil
+ }
+ log.Error(err, "Failed to acquire device lock")
+ return ctrl.Result{}, err
+ }
+ defer func() {
+ if err := r.Locker.ReleaseLock(ctx, device.Name, "probe-controller"); err != nil {
+ log.Error(err, "Failed to release device lock")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ conn, err := deviceutil.GetDeviceConnection(ctx, r, device)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ var cfg *provider.ProviderConfig
+ if obj.Spec.ProviderConfigRef != nil {
+ cfg, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ s := &probeScope{
+ Device: device,
+ Probe: obj,
+ Connection: conn,
+ ProviderConfig: cfg,
+ Provider: prov,
+ }
+
+ if !obj.DeletionTimestamp.IsZero() {
+ if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to remove finalizer from resource")
+ return ctrl.Result{}, err
+ }
+ }
+ log.V(3).Info("Resource is being deleted, skipping reconciliation")
+ return ctrl.Result{}, nil
+ }
+
+ // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers
+ if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to add finalizer to resource")
+ return ctrl.Result{}, err
+ }
+ log.V(1).Info("Added finalizer to resource")
+ return ctrl.Result{}, nil
+ }
+
+ orig := obj.DeepCopy()
+ if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) {
+ log.V(1).Info("Initializing status conditions")
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+
+ // Always attempt to update the metadata/status after reconciliation
+ defer func() {
+ if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) {
+ // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below
+ if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update resource metadata")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ if !equality.Semantic.DeepEqual(orig.Status, obj.Status) {
+ if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update status")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ }()
+
+ res, err := r.reconcile(ctx, s)
+ if err != nil {
+ log.Error(err, "Failed to reconcile resource")
+ return ctrl.Result{}, apistatus.WrapTerminalError(err)
+ }
+
+ return res, nil
+}
+
+func (r *ProbeReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
+ labelSelector := metav1.LabelSelector{}
+ if r.WatchFilterValue != "" {
+ labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue}
+ }
+
+ filter, err := predicate.LabelSelectorPredicate(labelSelector)
+ if err != nil {
+ return fmt.Errorf("failed to create label selector predicate: %w", err)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.Probe{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string {
+ o := obj.(*v1alpha1.Probe)
+ return []string{o.Spec.DeviceRef.Name}
+ }); err != nil {
+ return err
+ }
+
+ bldr := ctrl.NewControllerManagedBy(mgr).
+ For(&v1alpha1.Probe{}).
+ Named("probe").
+ WithEventFilter(filter)
+
+ for _, gvk := range v1alpha1.ProbeListDependencies {
+ obj := &unstructured.Unstructured{}
+ obj.SetGroupVersionKind(gvk)
+
+ bldr = bldr.Watches(
+ obj,
+ handler.EnqueueRequestsFromMapFunc(r.probesForProviderConfig),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ )
+ }
+
+ return bldr.
+ // Watches enqueues Probes when their referenced Device is created, deleted, or changes reachability.
+ Watches(
+ &v1alpha1.Device{},
+ handler.EnqueueRequestsFromMapFunc(r.deviceToProbes),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldDevice := e.ObjectOld.(*v1alpha1.Device)
+ newDevice := e.ObjectNew.(*v1alpha1.Device)
+ oldReachable := conditions.Get(oldDevice, v1alpha1.ReachableCondition)
+ newReachable := conditions.Get(newDevice, v1alpha1.ReachableCondition)
+ return oldReachable == nil || newReachable == nil || oldReachable.Status != newReachable.Status
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ // Watches enqueues Probes when a referenced Interface's ready state changes.
+ Watches(
+ &v1alpha1.Interface{},
+ handler.EnqueueRequestsFromMapFunc(r.interfaceToProbes),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldIntf := e.ObjectOld.(*v1alpha1.Interface)
+ newIntf := e.ObjectNew.(*v1alpha1.Interface)
+ return conditions.IsReady(oldIntf) != conditions.IsReady(newIntf)
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ // Watches enqueues Probes when a referenced VLAN's ready state changes.
+ Watches(
+ &v1alpha1.VLAN{},
+ handler.EnqueueRequestsFromMapFunc(r.vlanToProbes),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldVLAN := e.ObjectOld.(*v1alpha1.VLAN)
+ newVLAN := e.ObjectNew.(*v1alpha1.VLAN)
+ return conditions.IsReady(oldVLAN) != conditions.IsReady(newVLAN)
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ // Watches enqueues Probes when a referenced VRF's ready state changes.
+ Watches(
+ &v1alpha1.VRF{},
+ handler.EnqueueRequestsFromMapFunc(r.vrfToProbes),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldVRF := e.ObjectOld.(*v1alpha1.VRF)
+ newVRF := e.ObjectNew.(*v1alpha1.VRF)
+ return conditions.IsReady(oldVRF) != conditions.IsReady(newVRF)
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ Complete(r)
+}
+
+type probeScope struct {
+ Device *v1alpha1.Device
+ Probe *v1alpha1.Probe
+ Connection *deviceutil.Connection
+ ProviderConfig *provider.ProviderConfig
+ Provider provider.ProbeProvider
+}
+
+// AssertionError indicates the probe executed successfully but the assertion was not met.
+type AssertionError struct {
+ Message string
+}
+
+func (e *AssertionError) Error() string { return e.Message }
+
+func (r *ProbeReconciler) reconcile(ctx context.Context, s *probeScope) (res ctrl.Result, reterr error) {
+ if s.Probe.Labels == nil {
+ s.Probe.Labels = make(map[string]string)
+ }
+ s.Probe.Labels[v1alpha1.DeviceLabel] = s.Device.Name
+
+ // Ensure the Probe is owned by the Device.
+ if !controllerutil.HasControllerReference(s.Probe) {
+ if err := controllerutil.SetOwnerReference(s.Device, s.Probe, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ var schedule cron.Schedule
+ if s.Probe.Spec.Schedule != "" {
+ schedule, reterr = cron.ParseStandard(s.Probe.Spec.Schedule)
+ if reterr != nil {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.ScheduleInvalidReason,
+ Message: reterr.Error(),
+ })
+ return ctrl.Result{}, reconcile.TerminalError(reterr)
+ }
+
+ // Determine the last run time. If no probe has been executed yet,
+ // use the creation timestamp of the resource.
+ last := s.Probe.CreationTimestamp.UTC()
+ if s.Probe.Status.LastRunTime != nil {
+ last = s.Probe.Status.LastRunTime.UTC()
+ }
+
+ // If the next scheduled run is in the future, requeue until that time.
+ // Otherwise, continue to execute the probe now.
+ if now, next := time.Now().UTC(), schedule.Next(last); next.After(now) {
+ s.Probe.Status.NextRunTime = &metav1.Time{Time: next}
+ r.Recorder.Eventf(s.Probe, nil, "Normal", "Scheduled", "Reconcile", "Next probe scheduled at %s", next.Format(time.RFC3339))
+ return ctrl.Result{RequeueAfter: next.Sub(now)}, nil
+ }
+
+ // Update the next scheduled run time after the probe has executed.
+ defer func() {
+ if reterr != nil {
+ return
+ }
+ next := schedule.Next(time.Now().UTC())
+ s.Probe.Status.NextRunTime = &metav1.Time{Time: next}
+ r.Recorder.Eventf(s.Probe, nil, "Normal", "Scheduled", "Reconcile", "Next probe scheduled at %s", next.Format(time.RFC3339))
+ res.RequeueAfter = time.Until(next)
+ }()
+ }
+
+ if err := s.Provider.Connect(ctx, s.Connection); err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed to connect to provider: %w", err)
+ }
+ defer func() {
+ if err := s.Provider.Disconnect(ctx, s.Connection); err != nil {
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ if schedule == nil && s.Probe.Status.LastRunTime != nil {
+ // One-shot probe has already been executed, no further action is needed.
+ r.Recorder.Eventf(s.Probe, nil, "Normal", "ProbeCompleted", "Reconcile", "One-shot probe already completed at %s", s.Probe.Status.LastRunTime.String())
+ return ctrl.Result{}, nil
+ }
+
+ message, err := r.executeProbe(ctx, s)
+
+ now := metav1.Now()
+ s.Probe.Status.LastRunTime = &now
+
+ // Clear probe-type-specific status from previous runs.
+ if s.Probe.Spec.Type != v1alpha1.ProbeTypePing {
+ s.Probe.Status.Ping = nil
+ }
+
+ if err != nil {
+ if assertionErr, ok := errors.AsType[*AssertionError](err); ok {
+ // Assertion failed — probe ran but condition was not met.
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.ProbeFailedReason,
+ Message: assertionErr.Message,
+ })
+ r.Recorder.Eventf(s.Probe, nil, "Warning", "ProbeFailed", "Reconcile", "Probe assertion failed: %s", assertionErr.Message)
+ return ctrl.Result{}, nil
+ }
+ // Execution error — could not run the probe.
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.ProbeErrorReason,
+ Message: err.Error(),
+ })
+ r.Recorder.Eventf(s.Probe, nil, "Warning", "ProbeError", "Reconcile", "Failed to execute probe: %v", err)
+ return ctrl.Result{}, err
+ }
+
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionTrue,
+ Reason: v1alpha1.ProbeSuccessfulReason,
+ Message: message,
+ })
+ r.Recorder.Eventf(s.Probe, nil, "Normal", "ProbeSuccessful", "Reconcile", "Probe completed successfully")
+
+ return ctrl.Result{}, nil
+}
+
+// executeProbe dispatches to the appropriate provider method based on probe type.
+func (r *ProbeReconciler) executeProbe(ctx context.Context, s *probeScope) (string, error) { //nolint:gocyclo
+ spec := s.Probe.Spec
+ var err error
+
+ switch spec.Type {
+ case v1alpha1.ProbeTypePing:
+ req := &provider.PingRequest{
+ Address: spec.Ping.Address.String(),
+ ProviderConfig: s.ProviderConfig,
+ }
+ if spec.Ping.SourceInterface != nil {
+ req.SourceInterface, err = r.resolveInterfaceName(ctx, s, spec.Ping.SourceInterface)
+ if err != nil {
+ return "", err
+ }
+ }
+ if spec.Ping.VRF != nil {
+ req.VRF, err = r.resolveVRFName(ctx, s, spec.Ping.VRF)
+ if err != nil {
+ return "", err
+ }
+ }
+ if spec.Ping.Count != nil {
+ req.Count = *spec.Ping.Count
+ }
+ if spec.Ping.PacketSize != nil {
+ req.PacketSize = *spec.Ping.PacketSize
+ }
+ if spec.Ping.Timeout != nil {
+ req.Timeout = spec.Ping.Timeout.Duration
+ }
+ stats, err := s.Provider.Ping(ctx, req)
+ if err != nil {
+ return "", err
+ }
+
+ // Write ping stats into status.
+ s.Probe.Status.Ping = &v1alpha1.PingProbeResult{
+ Sent: stats.Sent,
+ Received: stats.Received,
+ }
+ if stats.MinTime > 0 {
+ s.Probe.Status.Ping.MinTime = &metav1.Duration{Duration: stats.MinTime}
+ }
+ if stats.AvgTime > 0 {
+ s.Probe.Status.Ping.AvgTime = &metav1.Duration{Duration: stats.AvgTime}
+ }
+ if stats.MaxTime > 0 {
+ s.Probe.Status.Ping.MaxTime = &metav1.Duration{Duration: stats.MaxTime}
+ }
+
+ if stats.Received != stats.Sent {
+ return "", &AssertionError{Message: fmt.Sprintf("%d/%d packets received", stats.Received, stats.Sent)}
+ }
+
+ message := fmt.Sprintf("%d/%d packets received", stats.Received, stats.Sent)
+ if stats.AvgTime > 0 {
+ message += fmt.Sprintf(", avg %s", stats.AvgTime)
+ }
+
+ return message, nil
+
+ case v1alpha1.ProbeTypeMACTableEntry:
+ req := &provider.MACTableRequest{
+ ProviderConfig: s.ProviderConfig,
+ }
+ if spec.MACTableEntry.VLAN != nil {
+ req.VLAN, err = r.resolveVLANID(ctx, s, spec.MACTableEntry.VLAN)
+ if err != nil {
+ return "", err
+ }
+ }
+ entries, err := s.Provider.GetMACTable(ctx, req)
+ if err != nil {
+ return "", err
+ }
+ targetMAC := spec.MACTableEntry.MACAddress
+ if !slices.ContainsFunc(entries, func(entry provider.MACTableEntry) bool {
+ return strings.EqualFold(entry.MACAddress, targetMAC)
+ }) {
+ return "", &AssertionError{Message: fmt.Sprintf("MAC %s not found in MAC table", targetMAC)}
+ }
+ return fmt.Sprintf("MAC %s found", targetMAC), nil
+
+ case v1alpha1.ProbeTypeRoutePresence:
+ req := &provider.RouteTableRequest{
+ ProviderConfig: s.ProviderConfig,
+ }
+ if spec.RoutePresence.VRF != nil {
+ req.VRF, err = r.resolveVRFName(ctx, s, spec.RoutePresence.VRF)
+ if err != nil {
+ return "", err
+ }
+ }
+ routes, err := s.Provider.GetRouteTable(ctx, req)
+ if err != nil {
+ return "", err
+ }
+ targetPrefix := spec.RoutePresence.Prefix.String()
+ if !slices.ContainsFunc(routes, func(route provider.RouteEntry) bool {
+ return route.Prefix == targetPrefix
+ }) {
+ return "", &AssertionError{Message: fmt.Sprintf("prefix %s not found in routing table", targetPrefix)}
+ }
+ return fmt.Sprintf("prefix %s found", targetPrefix), nil
+
+ case v1alpha1.ProbeTypeVTEPPeerConnectivity:
+ peers, err := s.Provider.GetVTEPPeers(ctx, &provider.VTEPPeersRequest{
+ ProviderConfig: s.ProviderConfig,
+ })
+ if err != nil {
+ return "", err
+ }
+ expected := spec.VTEPPeerConnectivity.ExpectedPeers
+ var missing []string
+ for _, ep := range expected {
+ if !slices.ContainsFunc(peers, func(peer provider.VTEPPeer) bool {
+ return peer.PeerIP == ep && peer.OperStatus
+ }) {
+ missing = append(missing, ep)
+ }
+ }
+ if len(missing) > 0 {
+ return "", &AssertionError{Message: fmt.Sprintf("VTEP peers not up: %v", missing)}
+ }
+ return fmt.Sprintf("%d/%d expected VTEP peers up", len(expected), len(expected)), nil
+
+ default:
+ return "", reconcile.TerminalError(fmt.Errorf("unsupported probe type: %s", spec.Type))
+ }
+}
+
+// resolveInterfaceName resolves an InterfaceSource to the device interface name.
+// If the source uses an InterfaceRef, the referenced Interface is fetched and validated
+// for existence, same-device ownership, and readiness. On failure, the Ready condition
+// is set to False on the Probe and a terminal error is returned.
+func (r *ProbeReconciler) resolveInterfaceName(ctx context.Context, s *probeScope, src *v1alpha1.InterfaceSource) (string, error) {
+ if src.Name != "" {
+ return src.Name, nil
+ }
+ if src.InterfaceRef == nil {
+ return "", nil
+ }
+
+ intf := new(v1alpha1.Interface)
+ key := client.ObjectKey{Namespace: s.Probe.Namespace, Name: src.InterfaceRef.Name}
+ if err := r.Get(ctx, key, intf); err != nil {
+ if apierrors.IsNotFound(err) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InterfaceNotFoundReason,
+ Message: fmt.Sprintf("referenced Interface %q not found", src.InterfaceRef.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced Interface %q not found", src.InterfaceRef.Name))
+ }
+ return "", fmt.Errorf("failed to get referenced Interface %q: %w", src.InterfaceRef.Name, err)
+ }
+
+ if intf.Spec.DeviceRef.Name != s.Device.Name {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.CrossDeviceReferenceReason,
+ Message: fmt.Sprintf("referenced Interface %q belongs to device %q, not %q", intf.Name, intf.Spec.DeviceRef.Name, s.Device.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced Interface %q belongs to device %q, not %q", intf.Name, intf.Spec.DeviceRef.Name, s.Device.Name))
+ }
+
+ if !conditions.IsReady(intf) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.WaitingForDependenciesReason,
+ Message: fmt.Sprintf("referenced Interface %q is not yet ready", intf.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced Interface %q is not yet ready", intf.Name))
+ }
+
+ return intf.Spec.Name, nil
+}
+
+// resolveVLANID resolves a VLANSource to the VLAN ID.
+// If the source uses a VLANRef, the referenced VLAN is fetched and validated
+// for existence, same-device ownership, and readiness. On failure, the Ready condition
+// is set to False on the Probe and a terminal error is returned.
+func (r *ProbeReconciler) resolveVLANID(ctx context.Context, s *probeScope, src *v1alpha1.VLANSource) (int16, error) {
+ if src.ID != nil {
+ return *src.ID, nil
+ }
+ if src.VLANRef == nil {
+ return 0, nil
+ }
+
+ vlan := new(v1alpha1.VLAN)
+ key := client.ObjectKey{Namespace: s.Probe.Namespace, Name: src.VLANRef.Name}
+ if err := r.Get(ctx, key, vlan); err != nil {
+ if apierrors.IsNotFound(err) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.VLANNotFoundReason,
+ Message: fmt.Sprintf("referenced VLAN %q not found", src.VLANRef.Name),
+ })
+ return 0, reconcile.TerminalError(fmt.Errorf("referenced VLAN %q not found", src.VLANRef.Name))
+ }
+ return 0, fmt.Errorf("failed to get referenced VLAN %q: %w", src.VLANRef.Name, err)
+ }
+
+ if vlan.Spec.DeviceRef.Name != s.Device.Name {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.CrossDeviceReferenceReason,
+ Message: fmt.Sprintf("referenced VLAN %q belongs to device %q, not %q", vlan.Name, vlan.Spec.DeviceRef.Name, s.Device.Name),
+ })
+ return 0, reconcile.TerminalError(fmt.Errorf("referenced VLAN %q belongs to device %q, not %q", vlan.Name, vlan.Spec.DeviceRef.Name, s.Device.Name))
+ }
+
+ if !conditions.IsReady(vlan) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.WaitingForDependenciesReason,
+ Message: fmt.Sprintf("referenced VLAN %q is not yet ready", vlan.Name),
+ })
+ return 0, reconcile.TerminalError(fmt.Errorf("referenced VLAN %q is not yet ready", vlan.Name))
+ }
+
+ return vlan.Spec.ID, nil
+}
+
+// resolveVRFName resolves a VRFSource to the device VRF name.
+// If the source uses a VRFRef, the referenced VRF is fetched and validated
+// for existence, same-device ownership, and readiness. On failure, the Ready condition
+// is set to False on the Probe and a terminal error is returned.
+func (r *ProbeReconciler) resolveVRFName(ctx context.Context, s *probeScope, src *v1alpha1.VRFSource) (string, error) {
+ if src.Name != "" {
+ return src.Name, nil
+ }
+ if src.VRFRef == nil {
+ return "", nil
+ }
+
+ vrf := new(v1alpha1.VRF)
+ key := client.ObjectKey{Namespace: s.Probe.Namespace, Name: src.VRFRef.Name}
+ if err := r.Get(ctx, key, vrf); err != nil {
+ if apierrors.IsNotFound(err) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.VRFNotFoundReason,
+ Message: fmt.Sprintf("referenced VRF %q not found", src.VRFRef.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced VRF %q not found", src.VRFRef.Name))
+ }
+ return "", fmt.Errorf("failed to get referenced VRF %q: %w", src.VRFRef.Name, err)
+ }
+
+ if vrf.Spec.DeviceRef.Name != s.Device.Name {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.CrossDeviceReferenceReason,
+ Message: fmt.Sprintf("referenced VRF %q belongs to device %q, not %q", vrf.Name, vrf.Spec.DeviceRef.Name, s.Device.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced VRF %q belongs to device %q, not %q", vrf.Name, vrf.Spec.DeviceRef.Name, s.Device.Name))
+ }
+
+ if !conditions.IsReady(vrf) {
+ conditions.Set(s.Probe, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.WaitingForDependenciesReason,
+ Message: fmt.Sprintf("referenced VRF %q is not yet ready", vrf.Name),
+ })
+ return "", reconcile.TerminalError(fmt.Errorf("referenced VRF %q is not yet ready", vrf.Name))
+ }
+
+ return vrf.Spec.Name, nil
+}
+
+// deviceToProbes is a [handler.MapFunc] to be used to enqueue requests for reconciliation
+// for Probes when their referenced Device's effective pause state changes.
+func (r *ProbeReconciler) deviceToProbes(ctx context.Context, obj client.Object) []ctrl.Request {
+ device, ok := obj.(*v1alpha1.Device)
+ if !ok {
+ panic(fmt.Sprintf("expected a Device but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device))
+
+ list := new(v1alpha1.ProbeList)
+ if err := r.List(
+ ctx, list,
+ client.InNamespace(device.Namespace),
+ client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name},
+ ); err != nil {
+ log.Error(err, "Failed to list Probes")
+ return nil
+ }
+
+ requests := make([]ctrl.Request, 0, len(list.Items))
+ for _, i := range list.Items {
+ log.V(2).Info("Enqueuing Probe for reconciliation", "Probe", klog.KObj(&i))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: i.Name,
+ Namespace: i.Namespace,
+ },
+ })
+ }
+
+ return requests
+}
+
+func (r *ProbeReconciler) probesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request {
+ log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj))
+
+ list := &v1alpha1.ProbeList{}
+ if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil {
+ log.Error(err, "Failed to list Probes")
+ return nil
+ }
+
+ gkv := obj.GetObjectKind().GroupVersionKind()
+
+ var requests []reconcile.Request
+ for _, m := range list.Items {
+ if m.Spec.ProviderConfigRef != nil &&
+ m.Spec.ProviderConfigRef.Name == obj.GetName() &&
+ m.Spec.ProviderConfigRef.Kind == gkv.Kind &&
+ m.Spec.ProviderConfigRef.APIVersion == gkv.GroupVersion().Identifier() {
+ log.V(2).Info("Enqueuing Probe for reconciliation", "Probe", klog.KObj(&m))
+ requests = append(requests, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: m.Name,
+ Namespace: m.Namespace,
+ },
+ })
+ }
+ }
+
+ return requests
+}
+
+// interfaceToProbes is a [handler.MapFunc] to enqueue Probes for reconciliation
+// when a referenced Interface's ready state changes.
+func (r *ProbeReconciler) interfaceToProbes(ctx context.Context, obj client.Object) []ctrl.Request {
+ intf, ok := obj.(*v1alpha1.Interface)
+ if !ok {
+ panic(fmt.Sprintf("expected an Interface but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf))
+
+ list := new(v1alpha1.ProbeList)
+ if err := r.List(ctx, list, client.InNamespace(intf.Namespace)); err != nil {
+ log.Error(err, "Failed to list Probes")
+ return nil
+ }
+
+ var requests []ctrl.Request
+ for _, p := range list.Items {
+ if slices.Contains(p.GetInterfaceReferences(), intf.Name) {
+ log.V(2).Info("Enqueuing Probe for reconciliation", "Probe", klog.KObj(&p))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: p.Name,
+ Namespace: p.Namespace,
+ },
+ })
+ }
+ }
+
+ return requests
+}
+
+// vlanToProbes is a [handler.MapFunc] to enqueue Probes for reconciliation
+// when a referenced VLAN's ready state changes.
+func (r *ProbeReconciler) vlanToProbes(ctx context.Context, obj client.Object) []ctrl.Request {
+ vlan, ok := obj.(*v1alpha1.VLAN)
+ if !ok {
+ panic(fmt.Sprintf("expected a VLAN but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "VLAN", klog.KObj(vlan))
+
+ list := new(v1alpha1.ProbeList)
+ if err := r.List(ctx, list, client.InNamespace(vlan.Namespace)); err != nil {
+ log.Error(err, "Failed to list Probes")
+ return nil
+ }
+
+ var requests []ctrl.Request
+ for _, p := range list.Items {
+ if slices.Contains(p.GetVLANReferences(), vlan.Name) {
+ log.V(2).Info("Enqueuing Probe for reconciliation", "Probe", klog.KObj(&p))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: p.Name,
+ Namespace: p.Namespace,
+ },
+ })
+ }
+ }
+
+ return requests
+}
+
+// vrfToProbes is a [handler.MapFunc] to enqueue Probes for reconciliation
+// when a referenced VRF's ready state changes.
+func (r *ProbeReconciler) vrfToProbes(ctx context.Context, obj client.Object) []ctrl.Request {
+ vrf, ok := obj.(*v1alpha1.VRF)
+ if !ok {
+ panic(fmt.Sprintf("expected a VRF but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf))
+
+ list := new(v1alpha1.ProbeList)
+ if err := r.List(ctx, list, client.InNamespace(vrf.Namespace)); err != nil {
+ log.Error(err, "Failed to list Probes")
+ return nil
+ }
+
+ var requests []ctrl.Request
+ for _, p := range list.Items {
+ if slices.Contains(p.GetVRFReferences(), vrf.Name) {
+ log.V(2).Info("Enqueuing Probe for reconciliation", "Probe", klog.KObj(&p))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: p.Name,
+ Namespace: p.Namespace,
+ },
+ })
+ }
+ }
+
+ return requests
+}
diff --git a/internal/controller/core/probe_controller_test.go b/internal/controller/core/probe_controller_test.go
new file mode 100644
index 000000000..549c25725
--- /dev/null
+++ b/internal/controller/core/probe_controller_test.go
@@ -0,0 +1,298 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package core
+
+import (
+ "errors"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+var _ = Describe("Probe Controller", func() {
+ Context("When reconciling a resource", func() {
+ var (
+ name string
+ key client.ObjectKey
+ )
+
+ BeforeEach(func() {
+ By("Creating the custom resource for the Kind Device")
+ device := &v1alpha1.Device{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-probe-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.DeviceSpec{
+ Endpoint: v1alpha1.Endpoint{
+ Address: "192.168.10.2:9339",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, device)).To(Succeed())
+ name = device.Name
+ key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault}
+ })
+
+ AfterEach(func() {
+ By("Cleaning up the Probe resource")
+ probe := &v1alpha1.Probe{}
+ probe.Name = name
+ probe.Namespace = metav1.NamespaceDefault
+ Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, probe))).To(Succeed())
+
+ By("Waiting for the Probe to be deleted")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ err := k8sClient.Get(ctx, key, resource)
+ g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
+ }).Should(Succeed())
+
+ By("Cleaning up the Device resource")
+ device := &v1alpha1.Device{}
+ device.Name = name
+ device.Namespace = metav1.NamespaceDefault
+ Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, device))).To(Succeed())
+ })
+
+ It("Should wait for its Device to become reachable", func() {
+ testProvider.SetConnectError(errors.New("device unreachable"))
+ DeferCleanup(func() { testProvider.SetConnectError(nil) })
+
+ Eventually(func(g Gomega) {
+ device := &v1alpha1.Device{}
+ g.Expect(k8sClient.Get(ctx, key, device)).To(Succeed())
+ g.Expect(device.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReachableCondition),
+ HaveField("Status", metav1.ConditionFalse),
+ )))
+ }).Should(Succeed())
+
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypePing,
+ Ping: &v1alpha1.PingProbe{
+ Address: v1alpha1.MustParseAddr("192.0.2.1"),
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).To(BeNil())
+ g.Expect(resource.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReadyCondition),
+ HaveField("Status", metav1.ConditionFalse),
+ HaveField("Reason", v1alpha1.UnreachableReason),
+ )))
+ }).Should(Succeed())
+
+ testProvider.SetConnectError(nil)
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ }).Should(Succeed())
+ })
+
+ It("Should execute a Probe when its Device is paused", func() {
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypePing,
+ Ping: &v1alpha1.PingProbe{
+ Address: v1alpha1.MustParseAddr("192.0.2.1"),
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ Eventually(func(g Gomega) {
+ device := &v1alpha1.Device{}
+ g.Expect(k8sClient.Get(ctx, key, device)).To(Succeed())
+ device.Spec.Paused = true
+ g.Expect(k8sClient.Update(ctx, device)).To(Succeed())
+ }).Should(Succeed())
+
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile a Ping Probe", func() {
+ By("Creating the custom resource for the Kind Probe")
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypePing,
+ Ping: &v1alpha1.PingProbe{
+ Address: v1alpha1.MustParseAddr("192.0.2.1"),
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ By("Adding a finalizer to the resource")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue())
+ }).Should(Succeed())
+
+ By("Adding the device label to the resource")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name))
+ }).Should(Succeed())
+
+ By("Adding the device as a owner reference")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.OwnerReferences).To(HaveLen(1))
+ g.Expect(resource.OwnerReferences[0].Kind).To(Equal("Device"))
+ g.Expect(resource.OwnerReferences[0].Name).To(Equal(name))
+ }).Should(Succeed())
+
+ By("Updating the resource status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ g.Expect(resource.Status.NextRunTime).To(BeNil())
+ g.Expect(resource.Status.Ping).NotTo(BeNil())
+ g.Expect(resource.Status.Ping.Sent).To(Equal(int32(3)))
+ g.Expect(resource.Status.Ping.Received).To(Equal(int32(3)))
+ g.Expect(resource.Status.Ping.AvgTime).NotTo(BeNil())
+ g.Expect(resource.Status.Ping.AvgTime.Duration).To(Equal(time.Millisecond))
+ g.Expect(resource.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReadyCondition),
+ HaveField("Status", metav1.ConditionTrue),
+ HaveField("Reason", v1alpha1.ProbeSuccessfulReason),
+ )))
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile a MAC table entry Probe", func() {
+ By("Creating the custom resource for the Kind Probe")
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypeMACTableEntry,
+ MACTableEntry: &v1alpha1.MACTableEntryProbe{
+ MACAddress: "00:1a:2b:3c:4d:5e",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ By("Updating the resource status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ g.Expect(resource.Status.NextRunTime).To(BeNil())
+ g.Expect(resource.Status.Ping).To(BeNil())
+ g.Expect(resource.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReadyCondition),
+ HaveField("Status", metav1.ConditionTrue),
+ HaveField("Reason", v1alpha1.ProbeSuccessfulReason),
+ )))
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile a route presence Probe", func() {
+ By("Creating the custom resource for the Kind Probe")
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypeRoutePresence,
+ RoutePresence: &v1alpha1.RoutePresenceProbe{
+ Prefix: v1alpha1.MustParsePrefix("10.100.0.0/16"),
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ By("Updating the resource status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ g.Expect(resource.Status.NextRunTime).To(BeNil())
+ g.Expect(resource.Status.Ping).To(BeNil())
+ g.Expect(resource.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReadyCondition),
+ HaveField("Status", metav1.ConditionTrue),
+ HaveField("Reason", v1alpha1.ProbeSuccessfulReason),
+ )))
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile a VTEP peer connectivity Probe", func() {
+ By("Creating the custom resource for the Kind Probe")
+ resource := &v1alpha1.Probe{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.ProbeSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Type: v1alpha1.ProbeTypeVTEPPeerConnectivity,
+ VTEPPeerConnectivity: &v1alpha1.VTEPPeerConnectivityProbe{
+ ExpectedPeers: []string{"192.0.2.10"},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+
+ By("Updating the resource status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.Probe{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.LastRunTime).NotTo(BeNil())
+ g.Expect(resource.Status.NextRunTime).To(BeNil())
+ g.Expect(resource.Status.Ping).To(BeNil())
+ g.Expect(resource.Status.Conditions).To(ContainElement(SatisfyAll(
+ HaveField("Type", v1alpha1.ReadyCondition),
+ HaveField("Status", metav1.ConditionTrue),
+ HaveField("Reason", v1alpha1.ProbeSuccessfulReason),
+ )))
+ }).Should(Succeed())
+ })
+ })
+})
diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go
index 56980c05e..c2b48318b 100644
--- a/internal/controller/core/suite_test.go
+++ b/internal/controller/core/suite_test.go
@@ -367,6 +367,15 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(ctx, k8sManager)
Expect(err).NotTo(HaveOccurred())
+ err = (&ProbeReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ Recorder: recorder,
+ Provider: prov,
+ Locker: testLocker,
+ }).SetupWithManager(ctx, k8sManager)
+ Expect(err).NotTo(HaveOccurred())
+
go func() {
defer GinkgoRecover()
err = k8sManager.Start(ctx)
@@ -439,6 +448,7 @@ var (
_ provider.DHCPRelayProvider = (*Provider)(nil)
_ provider.EthernetSegmentProvider = (*Provider)(nil)
_ provider.ConfigBackupProvider = (*Provider)(nil)
+ _ provider.ProbeProvider = (*Provider)(nil)
)
// Provider is a simple in-memory provider for testing purposes only.
@@ -1082,6 +1092,28 @@ func (p *Provider) GetEthernetSegment(name string) (string, bool) {
return esi, ok
}
+func (p *Provider) Ping(context.Context, *provider.PingRequest) (*provider.PingStats, error) {
+ return &provider.PingStats{
+ Sent: 3,
+ Received: 3,
+ MinTime: time.Millisecond,
+ AvgTime: time.Millisecond,
+ MaxTime: time.Millisecond,
+ }, nil
+}
+
+func (p *Provider) GetMACTable(context.Context, *provider.MACTableRequest) ([]provider.MACTableEntry, error) {
+ return []provider.MACTableEntry{{MACAddress: "00:1a:2b:3c:4d:5e"}}, nil
+}
+
+func (p *Provider) GetRouteTable(context.Context, *provider.RouteTableRequest) ([]provider.RouteEntry, error) {
+ return []provider.RouteEntry{{Prefix: "10.100.0.0/16"}}, nil
+}
+
+func (p *Provider) GetVTEPPeers(context.Context, *provider.VTEPPeersRequest) ([]provider.VTEPPeer, error) {
+ return []provider.VTEPPeer{{PeerIP: "192.0.2.10", OperStatus: true}}, nil
+}
+
// SetLLDPNeighbor is a test helper to configure LLDP neighbor information for an interface.
func (p *Provider) SetLLDPNeighbor(interfaceName, sysName, chassisID, portID string, ttl uint32) {
p.Lock()
diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go
index 4b838b5f3..ce16ab6f2 100644
--- a/internal/provider/cisco/nxos/provider.go
+++ b/internal/provider/cisco/nxos/provider.go
@@ -15,6 +15,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"maps"
"math"
"net/netip"
@@ -27,6 +28,7 @@ import (
"unicode/utf8"
"github.com/go-logr/logr"
+ systempb "github.com/openconfig/gnoi/system"
"google.golang.org/grpc"
nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
@@ -70,6 +72,7 @@ var (
_ provider.EthernetSegmentProvider = (*Provider)(nil)
_ provider.AAAProvider = (*Provider)(nil)
_ provider.ConfigBackupProvider = (*Provider)(nil)
+ _ provider.ProbeProvider = (*Provider)(nil)
)
// maxSetOperations is the maximum number of operations per gNMI Set RPC.
@@ -4058,6 +4061,259 @@ func (p *Provider) DeleteAAA(ctx context.Context, req *provider.DeleteAAARequest
return p.Do(ctx, sb)
}
+// InterfaceIPAddr retrieves an IP address of the requested family associated with the specified interface and VRF.
+// If the interface is not found or does not have an IP address of that family assigned, an error is returned.
+func (p *Provider) InterfaceIPAddr(ctx context.Context, name, vrf string, isIPv6 bool) (string, error) {
+ short, err := ShortName(name)
+ if err != nil {
+ return "", err
+ }
+ if vrf == "" {
+ vrf = DefaultVRFName
+ }
+ addr := &AddrItem{ID: short, Vrf: vrf, Is6: isIPv6}
+ if err := p.client.GetConfig(ctx, addr); err != nil && !errors.Is(err, gnmiext.ErrNil) {
+ return "", fmt.Errorf("failed to get IP address for interface %q in VRF %q: %w", name, vrf, err)
+ }
+ if !isIPv6 && addr.Unnumbered != "" {
+ return p.InterfaceIPAddr(ctx, addr.Unnumbered, vrf, false)
+ }
+ for _, a := range addr.AddrItems.AddrList {
+ if a.Type == IntfAddrTypePrimary {
+ ip, _, _ := strings.Cut(a.Addr, "/")
+ return ip, nil
+ }
+ }
+ family := "IPv4"
+ if isIPv6 {
+ family = "IPv6"
+ }
+ return "", apistatus.NewFailedPreconditionError(fmt.Sprintf("interface %q in VRF %q has no %s address configured", name, vrf, family))
+}
+
+func (p *Provider) Ping(ctx context.Context, req *provider.PingRequest) (*provider.PingStats, error) {
+ r := &systempb.PingRequest{
+ Destination: req.Address,
+ }
+ if req.SourceInterface != "" {
+ destination, err := netip.ParseAddr(req.Address)
+ if err != nil {
+ return nil, fmt.Errorf("invalid ping destination %q: %w", req.Address, err)
+ }
+ addr, err := p.InterfaceIPAddr(ctx, req.SourceInterface, req.VRF, destination.Is6())
+ if err != nil {
+ return nil, err
+ }
+ r.Source = addr
+ }
+ if req.VRF != "" {
+ r.NetworkInstance = req.VRF
+ }
+ if req.Count > 0 {
+ r.Count = req.Count
+ }
+ if req.PacketSize > 0 {
+ r.Size = req.PacketSize
+ }
+ if req.Timeout > 0 {
+ r.Wait = req.Timeout.Nanoseconds()
+ }
+ stream, err := systempb.NewSystemClient(p.conn).Ping(ctx, r, grpc.WaitForReady(true))
+ if err != nil {
+ return nil, err
+ }
+ // The gNOI Ping RPC is server-streaming. Each message is either a per-packet
+ // response or a final summary, so we read until the stream is closed and return
+ // use the last message for the statistics. As per the gNOI spec, the response
+ // must contain at least one message and provide summary statistics.
+ // See: https://github.com/openconfig/gnoi/blob/main/system/system.proto#L37-L41
+ var res *systempb.PingResponse
+ for {
+ resp, err := stream.Recv()
+ // gRPC returns io.EOF when the server closes the stream successfully.
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ res = resp
+ }
+ if res == nil {
+ return nil, fmt.Errorf("ping to %s returned no response", req.Address)
+ }
+ return &provider.PingStats{
+ Sent: res.GetSent(),
+ Received: res.GetReceived(),
+ MinTime: time.Duration(res.GetMinTime()),
+ AvgTime: time.Duration(res.GetAvgTime()),
+ MaxTime: time.Duration(res.GetMaxTime()),
+ }, nil
+}
+
+func (p *Provider) GetMACTable(ctx context.Context, req *provider.MACTableRequest) ([]provider.MACTableEntry, error) {
+ cmd := "show mac address-table"
+ if req.VLAN > 0 {
+ cmd = fmt.Sprintf("show mac address-table vlan %d", req.VLAN)
+ }
+ res, err := p.nxapi.Do(ctx, nxapi.NewRequest(cmd))
+ if err != nil {
+ return nil, err
+ }
+ if len(res) == 0 {
+ return nil, nil
+ }
+ var raw struct {
+ Table struct {
+ Row macTableRows `json:"ROW_mac_address"`
+ } `json:"TABLE_mac_address"`
+ }
+ if err := json.Unmarshal(res[0], &raw); err != nil {
+ return nil, err
+ }
+ entries := make([]provider.MACTableEntry, len(raw.Table.Row))
+ for i, row := range raw.Table.Row {
+ entries[i] = provider.MACTableEntry{
+ MACAddress: NormalizeMACAddress(row.MACAddr),
+ }
+ }
+ return entries, nil
+}
+
+func (p *Provider) GetRouteTable(ctx context.Context, req *provider.RouteTableRequest) ([]provider.RouteEntry, error) {
+ cmd := "show ip route"
+ if req.VRF != "" {
+ cmd = "show ip route vrf " + req.VRF
+ }
+ res, err := p.nxapi.Do(ctx, nxapi.NewRequest(cmd))
+ if err != nil {
+ return nil, err
+ }
+ if len(res) == 0 {
+ return nil, nil
+ }
+ var raw struct {
+ Table struct {
+ Row routeVRFRow `json:"ROW_vrf"`
+ } `json:"TABLE_vrf"`
+ }
+ if err := json.Unmarshal(res[0], &raw); err != nil {
+ return nil, err
+ }
+ var entries []provider.RouteEntry
+ for _, prefix := range raw.Table.Row.AddrFamily.Row.Prefixes.Row {
+ entries = append(entries, provider.RouteEntry{
+ Prefix: prefix.Prefix,
+ })
+ }
+ return entries, nil
+}
+
+func (p *Provider) GetVTEPPeers(ctx context.Context, _ *provider.VTEPPeersRequest) ([]provider.VTEPPeer, error) {
+ res, err := p.nxapi.Do(ctx, nxapi.NewRequest("show nve peers"))
+ if err != nil {
+ return nil, err
+ }
+ if len(res) == 0 {
+ return nil, nil
+ }
+ var raw struct {
+ Table struct {
+ Row nvePeerRows `json:"ROW_nve_peers"`
+ } `json:"TABLE_nve_peers"`
+ }
+ if err := json.Unmarshal(res[0], &raw); err != nil {
+ return nil, err
+ }
+ peers := make([]provider.VTEPPeer, 0, len(raw.Table.Row))
+ for _, row := range raw.Table.Row {
+ peers = append(peers, provider.VTEPPeer{
+ PeerIP: row.PeerIP,
+ OperStatus: row.PeerState == "Up",
+ })
+ }
+ return peers, nil
+}
+
+type macTableRow struct {
+ MACAddr string `json:"disp_mac_addr"`
+}
+
+type macTableRows []macTableRow
+
+func (r *macTableRows) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '{' {
+ var single macTableRow
+ if err := json.Unmarshal(data, &single); err != nil {
+ return err
+ }
+ *r = []macTableRow{single}
+ return nil
+ }
+ return json.Unmarshal(data, (*[]macTableRow)(r))
+}
+
+type routeVRFRow struct {
+ AddrFamily struct {
+ Row routeAddrFamilyRow `json:"ROW_addrf"`
+ } `json:"TABLE_addrf"`
+}
+
+type routeAddrFamilyRow struct {
+ Prefixes struct {
+ Row routePrefixRows `json:"ROW_prefix"`
+ } `json:"TABLE_prefix"`
+}
+
+type routePrefixRow struct {
+ Prefix string `json:"ipprefix"`
+}
+
+type routePrefixRows []routePrefixRow
+
+func (r *routePrefixRows) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '{' {
+ var single routePrefixRow
+ if err := json.Unmarshal(data, &single); err != nil {
+ return err
+ }
+ *r = []routePrefixRow{single}
+ return nil
+ }
+ return json.Unmarshal(data, (*[]routePrefixRow)(r))
+}
+
+type nvePeerRow struct {
+ PeerIP string `json:"peer-ip"`
+ PeerState string `json:"peer-state"`
+}
+
+type nvePeerRows []nvePeerRow
+
+func (r *nvePeerRows) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '{' {
+ var single nvePeerRow
+ if err := json.Unmarshal(data, &single); err != nil {
+ return err
+ }
+ *r = []nvePeerRow{single}
+ return nil
+ }
+ return json.Unmarshal(data, (*[]nvePeerRow)(r))
+}
+
+// NormalizeMACAddress converts a MAC address from NX-OS dotted format
+// (e.g., "0000.0000.0001") to colon-separated format (e.g., "00:00:00:00:00:01").
+func NormalizeMACAddress(mac string) string {
+ h := strings.ReplaceAll(mac, ".", "")
+ h = strings.ReplaceAll(h, ":", "")
+ h = strings.ReplaceAll(h, "-", "")
+ if len(h) != 12 {
+ return mac
+ }
+ return fmt.Sprintf("%s:%s:%s:%s:%s:%s", h[0:2], h[2:4], h[4:6], h[6:8], h[8:10], h[10:12])
+}
+
func init() {
provider.Register("cisco-nxos-gnmi", NewProvider)
}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index bb0bf7a06..dd38163d5 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -839,6 +839,88 @@ type EthernetSegmentStatus struct {
OperStatus bool
}
+// ProbeProvider executes probe assertions against a device.
+type ProbeProvider interface {
+ Provider
+
+ // Ping sends ICMP echo requests from the device to a target address and returns the statistics.
+ Ping(context.Context, *PingRequest) (*PingStats, error)
+ // GetMACTable retrieves MAC table entries from the device, optionally filtered by VLAN and VRF.
+ GetMACTable(context.Context, *MACTableRequest) ([]MACTableEntry, error)
+ // GetRouteTable retrieves routing table entries for a VRF from the device.
+ GetRouteTable(context.Context, *RouteTableRequest) ([]RouteEntry, error)
+ // GetVTEPPeers retrieves the NVE/VXLAN peer list from the device.
+ GetVTEPPeers(context.Context, *VTEPPeersRequest) ([]VTEPPeer, error)
+}
+
+// PingRequest contains the inputs for executing a ping probe.
+type PingRequest struct {
+ // Address is the target IP to ping.
+ Address string
+ // SourceInterface is the resolved source interface name on the device. May be empty.
+ SourceInterface string
+ // VRF is the resolved VRF name on the device. May be empty for the default VRF.
+ VRF string
+ // Count is the number of ICMP echo requests to send.
+ Count int32
+ // PacketSize is the ICMP payload size in bytes. Zero means use device default.
+ PacketSize int32
+ // Timeout is the maximum time to wait for a reply per echo request. Zero means use device default.
+ Timeout time.Duration
+
+ ProviderConfig *ProviderConfig
+}
+
+// PingStats contains the statistics returned by a Ping probe execution.
+type PingStats struct {
+ Sent int32
+ Received int32
+ MinTime time.Duration
+ AvgTime time.Duration
+ MaxTime time.Duration
+}
+
+// MACTableRequest contains the inputs for retrieving MAC table entries.
+type MACTableRequest struct {
+ // VLAN constrains the lookup to a specific VLAN ID. Zero means no VLAN filter-w>=
+ VLAN int16
+
+ ProviderConfig *ProviderConfig
+}
+
+// MACTableEntry represents a single entry in the device's MAC address table.
+type MACTableEntry struct {
+ // MACAddress is the MAC address (e.g., "00:1a:2b:3c:4d:5e").
+ MACAddress string
+}
+
+// RouteTableRequest contains the inputs for retrieving routing table entries.
+type RouteTableRequest struct {
+ // VRF is the resolved VRF name on the device. May be empty for the default VRF.
+ VRF string
+
+ ProviderConfig *ProviderConfig
+}
+
+// RouteEntry represents a single entry in the device's routing table.
+type RouteEntry struct {
+ // Prefix is the IP prefix (e.g., "10.100.0.0/16").
+ Prefix string
+}
+
+// VTEPPeersRequest contains the inputs for retrieving VTEP peer information.
+type VTEPPeersRequest struct {
+ ProviderConfig *ProviderConfig
+}
+
+// VTEPPeer represents a single NVE/VXLAN peer entry from the device.
+type VTEPPeer struct {
+ // PeerIP is the remote VTEP IP address.
+ PeerIP string
+ // OperStatus indicates whether the peer is operationally up (true) or down (false).
+ OperStatus bool
+}
+
var mu sync.RWMutex
// ProviderFunc returns a new [Provider] instance.