diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 426d7177..3d990685 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,7 +43,7 @@ Note: This requires a running NetBox instance that you can use (e.g. and create any user
- Open and create a token "0123456789abcdef0123456789abcdef01234567" with default settings
- - Open and create a custom field called "netboxOperatorRestorationHash" for Object types "IPAM > IP Address" and "IPAM > Prefix"
+ - Open and create a custom field called "netboxOperatorRestorationHash" for Object types "IPAM > IP Address", "IPAM > Prefix", "IPAM > IP Range" and "VPN > L2VPN"
- Open a new terminal window and export the following environment variables:
```bash
export NETBOX_HOST="demo.netbox.dev"
@@ -57,7 +57,7 @@ Note: This requires a running NetBox instance that you can use (e.g. ` and use your favorite Kubernetes tools to display.
+In the folder `config/samples/` you can find example manifests to create IpAddress, IpAddressClaim, Prefix, PrefixClaim, IpRange, IpRangeClaim, L2VPN and L2VPNClaim resources. Apply them to the cluster with `kubectl apply -f ` and use your favorite Kubernetes tools to display.
Example of assigning a Prefix using PrefixClaim:
diff --git a/PROJECT b/PROJECT
index 7ea2cef0..41a226a2 100644
--- a/PROJECT
+++ b/PROJECT
@@ -56,4 +56,20 @@ resources:
kind: IpRange
path: github.com/netbox-community/netbox-operator/api/v1
version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: netbox.dev
+ kind: L2VPNClaim
+ path: github.com/netbox-community/netbox-operator/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: netbox.dev
+ kind: L2VPN
+ path: github.com/netbox-community/netbox-operator/api/v1
+ version: v1
version: "3"
diff --git a/README.md b/README.md
index ed8d7bc6..b4dd4dc8 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
**Disclaimer:** This project is currently under development and may change rapidly, including breaking changes. Use with caution in production environments.
-NetBox Operator extends the Kubernetes API by allowing users to manage NetBox resources – such as IP addresses and prefixes – directly through Kubernetes. This integration brings Kubernetes-native features like reconciliation, ensuring that network configurations are maintained automatically, thereby improving both efficiency and reliability.
+NetBox Operator extends the Kubernetes API by allowing users to manage NetBox resources – such as IP addresses, prefixes, and L2VPNs – directly through Kubernetes. This integration brings Kubernetes-native features like reconciliation, ensuring that network configurations are maintained automatically, thereby improving both efficiency and reliability.
## The Claim Model
The NetBox Operator implements a "Claim Model" which is also used in the Kubernetes PersistentVolumeClaims (PVCs).
@@ -53,7 +53,7 @@ To optionally access the NetBox UI:
## Testing NetBox Operator using samples
-In the folder `config/samples/` you can find example manifests to create IpAddress, IpAddressClaim, Prefix, and PrefixClaim resources. Apply them to the cluster with `kubectl apply -f ` and use your favorite Kubernetes tools to display.
+In the folder `config/samples/` you can find example manifests to create IpAddress, IpAddressClaim, Prefix, PrefixClaim, L2VPN, and L2VPNClaim resources. Apply them to the cluster with `kubectl apply -f ` and use your favorite Kubernetes tools to display.
Example of assigning a Prefix using PrefixClaim:
@@ -74,6 +74,25 @@ for i in {001..100}; do
done
```
+# L2VPN Management
+
+NetBox Operator supports managing [L2VPNs](https://github.com/netbox-community/netbox/blob/main/docs/models/vpn/l2vpn.md) (Layer 2 VPNs, e.g. to track VXLAN VNIs) through two custom resources:
+
+- **L2VPN**: Represents a single L2VPN in NetBox. Similar to an IpAddress, it manages the lifecycle of a specific L2VPN (`name`, `type`, `identifier`).
+- **L2VPNClaim**: Claims a VNI for an L2VPN, either an exact `identifier` or the next free one from an `identifierRangeStart`/`identifierRangeEnd` range. Similar to IpAddressClaim, it creates a child L2VPN CR with the assigned identifier.
+
+Only VXLAN-based L2VPN types (`vxlan`, `vxlan-evpn`) are supported, since those are the ones that carry a VNI (4000-16777215) in their identifier.
+
+## Example: Claiming an L2VPN
+
+1. Apply an L2VPNClaim: `kubectl apply -f config/samples/netbox_v1_l2vpnclaim.yaml`
+2. Wait for ready condition: `kubectl wait l2vpnclaim l2vpnclaim-sample --for=condition=Ready`
+3. List L2VPNClaim and L2VPN resources: `kubectl get l2vc,l2v`
+
+`identifier` and `identifierRangeStart`/`identifierRangeEnd` are mutually exclusive on `L2VPNClaim` — set exactly one form. When a range is used, the operator picks the next free VNI in NetBox from that range.
+
+Restoration (via `preserveInNetbox: true`) works the same way as for IP Addresses and Prefixes — the L2VPN is preserved in NetBox upon CR deletion and can be reclaimed when the L2VPNClaim is re-created.
+
# Mixed usage of Prefixes
Note that NetBox does handle the Address management of Prefixes separately from IP Ranges and IP Addresses. This is important to know when you plan to use the same NetBox Prefix as a parentPrefix for your IpAddressClaims, IpRangeClaims and PrefixClaims.
@@ -97,13 +116,13 @@ The same applies if you use parentPrefixSelector with PrefixClaims. The above ex
In the case that the cluster containing the NetBox Custom Resources managed by this NetBox Operator is not backed up (e.g. using Velero), we need to be able to restore some information from NetBox. This includes two mechanisms implemented in this NetBox Operator:
-- `IpAddressClaim` and `PrefixClaim` have the flag `preserveInNetbox` in their spec. If set to true, the NetBox Operator will not delete the assigned IP Address/Prefix in NetBox when the Kubernetes Custom Resource is deleted
-- In NetBox, a custom field (by default `netboxOperatorRestorationHash`) is used to identify an IP Address/Prefix based on data from the IpAddressClaim/PrefixClaim resource
+- `IpAddressClaim`, `PrefixClaim`, and `L2VPNClaim` have the flag `preserveInNetbox` in their spec. If set to true, the NetBox Operator will not delete the assigned IP Address/Prefix/L2VPN in NetBox when the Kubernetes Custom Resource is deleted
+- In NetBox, a custom field (by default `netboxOperatorRestorationHash`) is used to identify an IP Address/Prefix/L2VPN based on data from the IpAddressClaim/PrefixClaim/L2VPNClaim resource
Use Cases for this Restoration:
- Disaster Recovery: In case the cluster is lost, IP Addresses can be restored with the IPAddressClaim only
-- Sticky IPs: Some services do not handle changes to IPs well. This ensures the IP/Prefix assigned to a Custom Resource is always the same.
+- Sticky IPs/VNIs: Some services do not handle changes to IPs or VNIs well. This ensures the IP/Prefix/VNI assigned to a Custom Resource is always the same.
# `ParentPrefixSelector` in `PrefixClaim`
diff --git a/api/v1/l2vpn_types.go b/api/v1/l2vpn_types.go
new file mode 100644
index 00000000..4a0481ee
--- /dev/null
+++ b/api/v1/l2vpn_types.go
@@ -0,0 +1,167 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// L2VPNSpec defines the desired state of L2VPN
+type L2VPNSpec struct {
+ // The name of the L2VPN in NetBox
+ // Field is immutable, required
+ //+kubebuilder:validation:Required
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'name' is immutable"
+ Name string `json:"name"`
+
+ // The NetBox L2VPN type. Only VXLAN-based types are supported, since those
+ // are the ones that carry a VNI in their identifier.
+ // Field is immutable, required
+ //+kubebuilder:validation:Required
+ //+kubebuilder:validation:Enum=vxlan;vxlan-evpn
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'type' is immutable"
+ Type string `json:"type"`
+
+ // The VNI to be assigned to this L2VPN in NetBox
+ // Field is immutable, required, range from 4000-16777215
+ //+kubebuilder:validation:Required
+ //+kubebuilder:validation:Minimum=4000
+ //+kubebuilder:validation:Maximum=16777215
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'identifier' is immutable"
+ Identifier int64 `json:"identifier"`
+
+ // The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value
+ // Field is immutable, not required
+ // Example: "Initech" or "Cyberdyne Systems"
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable"
+ Tenant string `json:"tenant,omitempty"`
+
+ // The NetBox Custom Fields that should be added to the resource in NetBox.
+ // Note that currently only Text Type is supported (GitHub #129)
+ // More info on NetBox Custom Fields:
+ // https://github.com/netbox-community/netbox/blob/main/docs/customization/custom-fields.md
+ // Field is mutable, not required
+ // Example:
+ // customfield1: "Production"
+ // customfield2: "This is a string"
+ CustomFields map[string]string `json:"customFields,omitempty"`
+
+ // Comment that should be added to the resource in NetBox
+ // Field is mutable, not required
+ Comments string `json:"comments,omitempty"`
+
+ // Description that should be added to the resource in NetBox
+ // Field is mutable, not required
+ Description string `json:"description,omitempty"`
+
+ // Defines whether the Resource should be preserved in NetBox when the
+ // Kubernetes Resource is deleted.
+ // - When set to true, the resource will not be deleted but preserved in
+ // NetBox upon CR deletion
+ // - When set to false, the resource will be cleaned up in NetBox
+ // upon CR deletion
+ // Setting preserveInNetbox to true is mandatory if the user wants to restore
+ // resources from NetBox (e.g. Sticky VNIs even if resources are deleted and
+ // recreated in Kubernetes)
+ // Field is mutable, not required
+ PreserveInNetbox bool `json:"preserveInNetbox,omitempty"`
+}
+
+// L2VPNStatus defines the observed state of L2VPN
+type L2VPNStatus struct {
+ // The ID of the resource in NetBox
+ L2VPNId int64 `json:"id,omitempty"`
+
+ // The slug generated for the resource in NetBox
+ Slug string `json:"slug,omitempty"`
+
+ // Last updated, corresponds to the 'last_updated' returned by NetBox when NetBox Operator updates a resource in NetBox.
+ // Format: date-time
+ LastUpdated metav1.Time `json:"lastUpdated,omitempty"`
+
+ // The URL to the resource in the NetBox UI. Note that the base of this
+ // URL depends on the runtime config of NetBox Operator
+ L2VPNUrl string `json:"url,omitempty"`
+
+ // Conditions represent the latest available observations of an object's state
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+//+kubebuilder:storageversion
+//+kubebuilder:printcolumn:name="Name",type=string,JSONPath=`.spec.name`
+//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
+//+kubebuilder:printcolumn:name="Identifier",type=integer,JSONPath=`.spec.identifier`
+//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
+//+kubebuilder:printcolumn:name="ID",type=string,JSONPath=`.status.id`
+//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
+//+kubebuilder:resource:shortName=l2v
+
+// L2VPN allows to create a NetBox L2VPN, e.g. to track a VXLAN VNI. More info about NetBox L2VPNs: https://github.com/netbox-community/netbox/blob/main/docs/models/vpn/l2vpn.md
+type L2VPN struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec L2VPNSpec `json:"spec,omitempty"`
+ Status L2VPNStatus `json:"status,omitempty"`
+}
+
+func (l *L2VPN) Conditions() *[]metav1.Condition {
+ return &l.Status.Conditions
+}
+
+//+kubebuilder:object:root=true
+
+// L2VPNList contains a list of L2VPN
+type L2VPNList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []L2VPN `json:"items"`
+}
+
+func init() {
+ register(&L2VPN{}, &L2VPNList{})
+}
+
+var ConditionL2VPNReadyTrue = metav1.Condition{
+ Type: "Ready",
+ Status: "True",
+ Reason: "L2VPNReservedInNetbox",
+ Message: "L2VPN was reserved/updated in NetBox",
+}
+
+var ConditionL2VPNReadyFalse = metav1.Condition{
+ Type: "Ready",
+ Status: "False",
+ Reason: "FailedToReserveL2VPNInNetbox",
+ Message: "Failed to reserve L2VPN in NetBox",
+}
+
+var ConditionL2VPNReadyFalseDeletionInProgress = metav1.Condition{
+ Type: "Ready",
+ Status: "False",
+ Reason: "DeletionInProgress",
+ Message: "L2VPN deletion in progress",
+}
+
+var ConditionL2VPNReadyFalseDeletionFailed = metav1.Condition{
+ Type: "Ready",
+ Status: "False",
+ Reason: "FailedToDeleteL2VPNInNetbox",
+ Message: "Failed to delete L2VPN in NetBox",
+}
diff --git a/api/v1/l2vpnclaim_types.go b/api/v1/l2vpnclaim_types.go
new file mode 100644
index 00000000..2f23b8cd
--- /dev/null
+++ b/api/v1/l2vpnclaim_types.go
@@ -0,0 +1,176 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// L2VPNClaimSpec defines the desired state of L2VPNClaim
+// +kubebuilder:validation:XValidation:rule="(has(self.identifier) && !has(self.identifierRangeStart) && !has(self.identifierRangeEnd)) || (!has(self.identifier) && has(self.identifierRangeStart) && has(self.identifierRangeEnd))",message="Exactly one of 'identifier' or ('identifierRangeStart' and 'identifierRangeEnd') must be set"
+type L2VPNClaimSpec struct {
+ // The NetBox L2VPN type. Only VXLAN-based types are supported, since those
+ // are the ones that carry a VNI in their identifier.
+ // Field is immutable, required
+ //+kubebuilder:validation:Required
+ //+kubebuilder:validation:Enum=vxlan;vxlan-evpn
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'type' is immutable"
+ Type string `json:"type"`
+
+ // The exact VNI to claim. Mutually exclusive with identifierRangeStart/identifierRangeEnd.
+ // Field is immutable, not required, range from 4000-16777215
+ //+kubebuilder:validation:Minimum=4000
+ //+kubebuilder:validation:Maximum=16777215
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'identifier' is immutable"
+ Identifier int64 `json:"identifier,omitempty"`
+
+ // The lower bound (inclusive) of the range to pick a free VNI from.
+ // Mutually exclusive with identifier, required together with identifierRangeEnd.
+ // Field is immutable
+ //+kubebuilder:validation:Minimum=4000
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'identifierRangeStart' is immutable"
+ IdentifierRangeStart int64 `json:"identifierRangeStart,omitempty"`
+
+ // The upper bound (inclusive) of the range to pick a free VNI from.
+ // Mutually exclusive with identifier, required together with identifierRangeStart.
+ // Field is immutable
+ //+kubebuilder:validation:Maximum=16777215
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'identifierRangeEnd' is immutable"
+ IdentifierRangeEnd int64 `json:"identifierRangeEnd,omitempty"`
+
+ // The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value
+ // Field is immutable, not required
+ // Example: "Initech" or "Cyberdyne Systems"
+ //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable"
+ Tenant string `json:"tenant,omitempty"`
+
+ // The NetBox Custom Fields that should be added to the resource in NetBox.
+ // Note that currently only Text Type is supported (GitHub #129)
+ // More info on NetBox Custom Fields:
+ // https://github.com/netbox-community/netbox/blob/main/docs/customization/custom-fields.md
+ // Field is mutable, not required
+ // Example:
+ // customfield1: "Production"
+ // customfield2: "This is a string"
+ CustomFields map[string]string `json:"customFields,omitempty"`
+
+ // Comment that should be added to the resource in NetBox
+ // Field is mutable, not required
+ Comments string `json:"comments,omitempty"`
+
+ // Description that should be added to the resource in NetBox
+ // Field is mutable, not required
+ Description string `json:"description,omitempty"`
+
+ // Defines whether the Resource should be preserved in NetBox when the
+ // Kubernetes Resource is deleted.
+ // - When set to true, the resource will not be deleted but preserved in
+ // NetBox upon CR deletion
+ // - When set to false, the resource will be cleaned up in NetBox
+ // upon CR deletion
+ // Setting preserveInNetbox to true is mandatory if the user wants to restore
+ // resources from NetBox (e.g. Sticky VNIs even if resources are deleted and
+ // recreated in Kubernetes)
+ // Field is mutable, not required
+ PreserveInNetbox bool `json:"preserveInNetbox,omitempty"`
+}
+
+// L2VPNClaimStatus defines the observed state of L2VPNClaim
+type L2VPNClaimStatus struct {
+ // The assigned VNI
+ Identifier int64 `json:"identifier,omitempty"`
+
+ // The name of the L2VPN CR created by the L2VPNClaim Controller
+ L2VPNName string `json:"l2VPNName,omitempty"`
+
+ // Conditions represent the latest available observations of an object's state
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+//+kubebuilder:storageversion
+//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
+//+kubebuilder:printcolumn:name="Identifier",type=integer,JSONPath=`.status.identifier`
+//+kubebuilder:printcolumn:name="L2VPNAssigned",type=string,JSONPath=`.status.conditions[?(@.type=="L2VPNAssigned")].status`
+//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
+//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
+//+kubebuilder:resource:shortName=l2vc
+
+// L2VPNClaim allows to claim a VNI for a NetBox L2VPN, either an exact one or
+// the next free one in a range. The L2VPNClaim Controller will try to assign
+// an available identifier and if successful it will create the L2VPN CR.
+// More info about NetBox L2VPNs: https://github.com/netbox-community/netbox/blob/main/docs/models/vpn/l2vpn.md
+type L2VPNClaim struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec L2VPNClaimSpec `json:"spec,omitempty"`
+ Status L2VPNClaimStatus `json:"status,omitempty"`
+}
+
+func (l *L2VPNClaim) Conditions() *[]metav1.Condition {
+ return &l.Status.Conditions
+}
+
+//+kubebuilder:object:root=true
+
+// L2VPNClaimList contains a list of L2VPNClaim
+type L2VPNClaimList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []L2VPNClaim `json:"items"`
+}
+
+func init() {
+ register(&L2VPNClaim{}, &L2VPNClaimList{})
+}
+
+var ConditionL2VPNClaimReadyTrue = metav1.Condition{
+ Type: "Ready",
+ Status: "True",
+ Reason: "L2VPNResourceReady",
+ Message: "L2VPN Resource is ready",
+}
+
+var ConditionL2VPNClaimReadyFalse = metav1.Condition{
+ Type: "Ready",
+ Status: "False",
+ Reason: "L2VPNResourceNotReady",
+ Message: "L2VPN Resource is not ready",
+}
+
+var ConditionL2VPNAssignedTrue = metav1.Condition{
+ Type: "L2VPNAssigned",
+ Status: "True",
+ Reason: "L2VPNCRCreated",
+ Message: "New identifier fetched from NetBox and L2VPN CR was created",
+}
+
+var ConditionL2VPNAssignedFalse = metav1.Condition{
+ Type: "L2VPNAssigned",
+ Status: "False",
+ Reason: "L2VPNCRNotCreated",
+ Message: "Failed to fetch new identifier from NetBox",
+}
+
+var ConditionL2VPNAssignedFalseRangeExhausted = metav1.Condition{
+ Type: "L2VPNAssigned",
+ Status: "False",
+ Reason: "L2VPNCRNotCreated",
+ Message: "No free identifier available in the requested range",
+}
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 921a0f66..89ca4523 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -449,6 +449,213 @@ func (in *IpRangeStatus) DeepCopy() *IpRangeStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *L2VPN) DeepCopyInto(out *L2VPN) {
+ *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 L2VPN.
+func (in *L2VPN) DeepCopy() *L2VPN {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPN)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *L2VPN) 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 *L2VPNClaim) DeepCopyInto(out *L2VPNClaim) {
+ *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 L2VPNClaim.
+func (in *L2VPNClaim) DeepCopy() *L2VPNClaim {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNClaim)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *L2VPNClaim) 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 *L2VPNClaimList) DeepCopyInto(out *L2VPNClaimList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]L2VPNClaim, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNClaimList.
+func (in *L2VPNClaimList) DeepCopy() *L2VPNClaimList {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNClaimList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *L2VPNClaimList) 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 *L2VPNClaimSpec) DeepCopyInto(out *L2VPNClaimSpec) {
+ *out = *in
+ if in.CustomFields != nil {
+ in, out := &in.CustomFields, &out.CustomFields
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNClaimSpec.
+func (in *L2VPNClaimSpec) DeepCopy() *L2VPNClaimSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNClaimSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *L2VPNClaimStatus) DeepCopyInto(out *L2VPNClaimStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNClaimStatus.
+func (in *L2VPNClaimStatus) DeepCopy() *L2VPNClaimStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNClaimStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *L2VPNList) DeepCopyInto(out *L2VPNList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]L2VPN, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNList.
+func (in *L2VPNList) DeepCopy() *L2VPNList {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *L2VPNList) 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 *L2VPNSpec) DeepCopyInto(out *L2VPNSpec) {
+ *out = *in
+ if in.CustomFields != nil {
+ in, out := &in.CustomFields, &out.CustomFields
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNSpec.
+func (in *L2VPNSpec) DeepCopy() *L2VPNSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *L2VPNStatus) DeepCopyInto(out *L2VPNStatus) {
+ *out = *in
+ in.LastUpdated.DeepCopyInto(&out.LastUpdated)
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new L2VPNStatus.
+func (in *L2VPNStatus) DeepCopy() *L2VPNStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(L2VPNStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Prefix) DeepCopyInto(out *Prefix) {
*out = *in
diff --git a/cmd/main.go b/cmd/main.go
index c27fbd57..699a53e9 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -243,6 +243,28 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "IpRange")
os.Exit(1)
}
+ if err = (&controller.L2VPNClaimReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ EventStatusRecorder: controller.NewEventStatusRecorder(mgr.GetEventRecorderFor("l2vpn-claim-controller")), //nolint:staticcheck // using deprecated API until controller-runtime migration is complete
+ NetboxClient: netboxCompositeClient,
+ OperatorNamespace: operatorNamespace,
+ RestConfig: mgr.GetConfig(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "L2VPNClaim")
+ os.Exit(1)
+ }
+ if err = (&controller.L2VPNReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ EventStatusRecorder: controller.NewEventStatusRecorder(mgr.GetEventRecorderFor("l2vpn-controller")), //nolint:staticcheck // using deprecated API until controller-runtime migration is complete
+ NetboxClient: netboxCompositeClient,
+ OperatorNamespace: operatorNamespace,
+ RestConfig: mgr.GetConfig(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "L2VPN")
+ os.Exit(1)
+ }
//+kubebuilder:scaffold:builder
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
diff --git a/config/crd/bases/netbox.dev_l2vpnclaims.yaml b/config/crd/bases/netbox.dev_l2vpnclaims.yaml
new file mode 100644
index 00000000..6ea2b226
--- /dev/null
+++ b/config/crd/bases/netbox.dev_l2vpnclaims.yaml
@@ -0,0 +1,235 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.16.4
+ name: l2vpnclaims.netbox.dev
+spec:
+ group: netbox.dev
+ names:
+ kind: L2VPNClaim
+ listKind: L2VPNClaimList
+ plural: l2vpnclaims
+ shortNames:
+ - l2vc
+ singular: l2vpnclaim
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.type
+ name: Type
+ type: string
+ - jsonPath: .status.identifier
+ name: Identifier
+ type: integer
+ - jsonPath: .status.conditions[?(@.type=="L2VPNAssigned")].status
+ name: L2VPNAssigned
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ L2VPNClaim allows to claim a VNI for a NetBox L2VPN, either an exact one or
+ the next free one in a range. The L2VPNClaim Controller will try to assign
+ an available identifier and if successful it will create the L2VPN CR.
+ More info about NetBox L2VPNs: https://github.com/netbox-community/netbox/blob/main/docs/models/vpn/l2vpn.md
+ 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: L2VPNClaimSpec defines the desired state of L2VPNClaim
+ properties:
+ comments:
+ description: |-
+ Comment that should be added to the resource in NetBox
+ Field is mutable, not required
+ type: string
+ customFields:
+ additionalProperties:
+ type: string
+ description: |-
+ The NetBox Custom Fields that should be added to the resource in NetBox.
+ Note that currently only Text Type is supported (GitHub #129)
+ More info on NetBox Custom Fields:
+ https://github.com/netbox-community/netbox/blob/main/docs/customization/custom-fields.md
+ Field is mutable, not required
+ Example:
+ customfield1: "Production"
+ customfield2: "This is a string"
+ type: object
+ description:
+ description: |-
+ Description that should be added to the resource in NetBox
+ Field is mutable, not required
+ type: string
+ identifier:
+ description: |-
+ The exact VNI to claim. Mutually exclusive with identifierRangeStart/identifierRangeEnd.
+ Field is immutable, not required, range from 4000-16777215
+ format: int64
+ maximum: 16777215
+ minimum: 4000
+ type: integer
+ x-kubernetes-validations:
+ - message: Field 'identifier' is immutable
+ rule: self == oldSelf
+ identifierRangeEnd:
+ description: |-
+ The upper bound (inclusive) of the range to pick a free VNI from.
+ Mutually exclusive with identifier, required together with identifierRangeStart.
+ Field is immutable
+ format: int64
+ maximum: 16777215
+ type: integer
+ x-kubernetes-validations:
+ - message: Field 'identifierRangeEnd' is immutable
+ rule: self == oldSelf
+ identifierRangeStart:
+ description: |-
+ The lower bound (inclusive) of the range to pick a free VNI from.
+ Mutually exclusive with identifier, required together with identifierRangeEnd.
+ Field is immutable
+ format: int64
+ minimum: 4000
+ type: integer
+ x-kubernetes-validations:
+ - message: Field 'identifierRangeStart' is immutable
+ rule: self == oldSelf
+ preserveInNetbox:
+ description: |-
+ Defines whether the Resource should be preserved in NetBox when the
+ Kubernetes Resource is deleted.
+ - When set to true, the resource will not be deleted but preserved in
+ NetBox upon CR deletion
+ - When set to false, the resource will be cleaned up in NetBox
+ upon CR deletion
+ Setting preserveInNetbox to true is mandatory if the user wants to restore
+ resources from NetBox (e.g. Sticky VNIs even if resources are deleted and
+ recreated in Kubernetes)
+ Field is mutable, not required
+ type: boolean
+ tenant:
+ description: |-
+ The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value
+ Field is immutable, not required
+ Example: "Initech" or "Cyberdyne Systems"
+ type: string
+ x-kubernetes-validations:
+ - message: Field 'tenant' is immutable
+ rule: self == oldSelf
+ type:
+ description: |-
+ The NetBox L2VPN type. Only VXLAN-based types are supported, since those
+ are the ones that carry a VNI in their identifier.
+ Field is immutable, required
+ enum:
+ - vxlan
+ - vxlan-evpn
+ type: string
+ x-kubernetes-validations:
+ - message: Field 'type' is immutable
+ rule: self == oldSelf
+ required:
+ - type
+ type: object
+ x-kubernetes-validations:
+ - message: Exactly one of 'identifier' or ('identifierRangeStart' and
+ 'identifierRangeEnd') must be set
+ rule: (has(self.identifier) && !has(self.identifierRangeStart) && !has(self.identifierRangeEnd))
+ || (!has(self.identifier) && has(self.identifierRangeStart) && has(self.identifierRangeEnd))
+ status:
+ description: L2VPNClaimStatus defines the observed state of L2VPNClaim
+ properties:
+ conditions:
+ description: Conditions represent the latest available observations
+ of an object's state
+ 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
+ identifier:
+ description: The assigned VNI
+ format: int64
+ type: integer
+ l2VPNName:
+ description: The name of the L2VPN CR created by the L2VPNClaim Controller
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/bases/netbox.dev_l2vpns.yaml b/config/crd/bases/netbox.dev_l2vpns.yaml
new file mode 100644
index 00000000..ea5949e3
--- /dev/null
+++ b/config/crd/bases/netbox.dev_l2vpns.yaml
@@ -0,0 +1,229 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.16.4
+ name: l2vpns.netbox.dev
+spec:
+ group: netbox.dev
+ names:
+ kind: L2VPN
+ listKind: L2VPNList
+ plural: l2vpns
+ shortNames:
+ - l2v
+ singular: l2vpn
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.name
+ name: Name
+ type: string
+ - jsonPath: .spec.type
+ name: Type
+ type: string
+ - jsonPath: .spec.identifier
+ name: Identifier
+ type: integer
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.id
+ name: ID
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: 'L2VPN allows to create a NetBox L2VPN, e.g. to track a VXLAN
+ VNI. More info about NetBox L2VPNs: https://github.com/netbox-community/netbox/blob/main/docs/models/vpn/l2vpn.md'
+ 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: L2VPNSpec defines the desired state of L2VPN
+ properties:
+ comments:
+ description: |-
+ Comment that should be added to the resource in NetBox
+ Field is mutable, not required
+ type: string
+ customFields:
+ additionalProperties:
+ type: string
+ description: |-
+ The NetBox Custom Fields that should be added to the resource in NetBox.
+ Note that currently only Text Type is supported (GitHub #129)
+ More info on NetBox Custom Fields:
+ https://github.com/netbox-community/netbox/blob/main/docs/customization/custom-fields.md
+ Field is mutable, not required
+ Example:
+ customfield1: "Production"
+ customfield2: "This is a string"
+ type: object
+ description:
+ description: |-
+ Description that should be added to the resource in NetBox
+ Field is mutable, not required
+ type: string
+ identifier:
+ description: |-
+ The VNI to be assigned to this L2VPN in NetBox
+ Field is immutable, required, range from 4000-16777215
+ format: int64
+ maximum: 16777215
+ minimum: 4000
+ type: integer
+ x-kubernetes-validations:
+ - message: Field 'identifier' is immutable
+ rule: self == oldSelf
+ name:
+ description: |-
+ The name of the L2VPN in NetBox
+ Field is immutable, required
+ type: string
+ x-kubernetes-validations:
+ - message: Field 'name' is immutable
+ rule: self == oldSelf
+ preserveInNetbox:
+ description: |-
+ Defines whether the Resource should be preserved in NetBox when the
+ Kubernetes Resource is deleted.
+ - When set to true, the resource will not be deleted but preserved in
+ NetBox upon CR deletion
+ - When set to false, the resource will be cleaned up in NetBox
+ upon CR deletion
+ Setting preserveInNetbox to true is mandatory if the user wants to restore
+ resources from NetBox (e.g. Sticky VNIs even if resources are deleted and
+ recreated in Kubernetes)
+ Field is mutable, not required
+ type: boolean
+ tenant:
+ description: |-
+ The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value
+ Field is immutable, not required
+ Example: "Initech" or "Cyberdyne Systems"
+ type: string
+ x-kubernetes-validations:
+ - message: Field 'tenant' is immutable
+ rule: self == oldSelf
+ type:
+ description: |-
+ The NetBox L2VPN type. Only VXLAN-based types are supported, since those
+ are the ones that carry a VNI in their identifier.
+ Field is immutable, required
+ enum:
+ - vxlan
+ - vxlan-evpn
+ type: string
+ x-kubernetes-validations:
+ - message: Field 'type' is immutable
+ rule: self == oldSelf
+ required:
+ - identifier
+ - name
+ - type
+ type: object
+ status:
+ description: L2VPNStatus defines the observed state of L2VPN
+ properties:
+ conditions:
+ description: Conditions represent the latest available observations
+ of an object's state
+ 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
+ id:
+ description: The ID of the resource in NetBox
+ format: int64
+ type: integer
+ lastUpdated:
+ description: |-
+ Last updated, corresponds to the 'last_updated' returned by NetBox when NetBox Operator updates a resource in NetBox.
+ Format: date-time
+ format: date-time
+ type: string
+ slug:
+ description: The slug generated for the resource in NetBox
+ type: string
+ url:
+ description: |-
+ The URL to the resource in the NetBox UI. Note that the base of this
+ URL depends on the runtime config of NetBox Operator
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 32c474b6..e6167da5 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -8,6 +8,8 @@ resources:
- bases/netbox.dev_prefixclaims.yaml
- bases/netbox.dev_iprangeclaims.yaml
- bases/netbox.dev_ipranges.yaml
+- bases/netbox.dev_l2vpnclaims.yaml
+- bases/netbox.dev_l2vpns.yaml
#+kubebuilder:scaffold:crdkustomizeresource
patches:
diff --git a/config/rbac/l2vpn_editor_role.yaml b/config/rbac/l2vpn_editor_role.yaml
new file mode 100644
index 00000000..f90010cd
--- /dev/null
+++ b/config/rbac/l2vpn_editor_role.yaml
@@ -0,0 +1,31 @@
+# permissions for end users to edit l2vpns.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: clusterrole
+ app.kubernetes.io/instance: l2vpn-editor-role
+ app.kubernetes.io/component: rbac
+ app.kubernetes.io/created-by: netbox-operator
+ app.kubernetes.io/part-of: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpn-editor-role
+rules:
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpns
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpns/status
+ verbs:
+ - get
diff --git a/config/rbac/l2vpn_viewer_role.yaml b/config/rbac/l2vpn_viewer_role.yaml
new file mode 100644
index 00000000..01bd1b99
--- /dev/null
+++ b/config/rbac/l2vpn_viewer_role.yaml
@@ -0,0 +1,27 @@
+# permissions for end users to view l2vpns.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: clusterrole
+ app.kubernetes.io/instance: l2vpn-viewer-role
+ app.kubernetes.io/component: rbac
+ app.kubernetes.io/created-by: netbox-operator
+ app.kubernetes.io/part-of: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpn-viewer-role
+rules:
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpns
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpns/status
+ verbs:
+ - get
diff --git a/config/rbac/l2vpnclaim_editor_role.yaml b/config/rbac/l2vpnclaim_editor_role.yaml
new file mode 100644
index 00000000..060ec07b
--- /dev/null
+++ b/config/rbac/l2vpnclaim_editor_role.yaml
@@ -0,0 +1,31 @@
+# permissions for end users to edit l2vpnclaims.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: clusterrole
+ app.kubernetes.io/instance: l2vpnclaim-editor-role
+ app.kubernetes.io/component: rbac
+ app.kubernetes.io/created-by: netbox-operator
+ app.kubernetes.io/part-of: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpnclaim-editor-role
+rules:
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpnclaims
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpnclaims/status
+ verbs:
+ - get
diff --git a/config/rbac/l2vpnclaim_viewer_role.yaml b/config/rbac/l2vpnclaim_viewer_role.yaml
new file mode 100644
index 00000000..99a02ed7
--- /dev/null
+++ b/config/rbac/l2vpnclaim_viewer_role.yaml
@@ -0,0 +1,27 @@
+# permissions for end users to view l2vpnclaims.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: clusterrole
+ app.kubernetes.io/instance: l2vpnclaim-viewer-role
+ app.kubernetes.io/component: rbac
+ app.kubernetes.io/created-by: netbox-operator
+ app.kubernetes.io/part-of: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpnclaim-viewer-role
+rules:
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpnclaims
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - netbox.dev
+ resources:
+ - l2vpnclaims/status
+ verbs:
+ - get
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 9ce32be3..c1038559 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -18,6 +18,8 @@ rules:
- ipaddresses
- iprangeclaims
- ipranges
+ - l2vpnclaims
+ - l2vpns
- prefixclaims
- prefixes
verbs:
@@ -35,6 +37,8 @@ rules:
- ipaddresses/finalizers
- iprangeclaims/finalizers
- ipranges/finalizers
+ - l2vpnclaims/finalizers
+ - l2vpns/finalizers
- prefixclaims/finalizers
- prefixes/finalizers
verbs:
@@ -46,6 +50,8 @@ rules:
- ipaddresses/status
- iprangeclaims/status
- ipranges/status
+ - l2vpnclaims/status
+ - l2vpns/status
- prefixclaims/status
- prefixes/status
verbs:
diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml
index 674e3bc8..37527749 100644
--- a/config/samples/kustomization.yaml
+++ b/config/samples/kustomization.yaml
@@ -9,4 +9,7 @@ resources:
- netbox_v1_prefixclaim_parentprefixselector.yaml
- netbox_v1_iprangeclaim.yaml
- netbox_v1_iprange.yaml
+ - netbox_v1_l2vpnclaim.yaml
+ - netbox_v1_l2vpnclaim_range.yaml
+ - netbox_v1_l2vpn.yaml
# +kubebuilder:scaffold:manifestskustomizesamples
diff --git a/config/samples/netbox_v1_l2vpn.yaml b/config/samples/netbox_v1_l2vpn.yaml
new file mode 100644
index 00000000..bb4aa339
--- /dev/null
+++ b/config/samples/netbox_v1_l2vpn.yaml
@@ -0,0 +1,16 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPN
+metadata:
+ labels:
+ app.kubernetes.io/name: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpn-sample
+spec:
+ tenant: "Dunder-Mifflin, Inc."
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: true
+ name: "l2vpn-sample"
+ type: "vxlan-evpn"
+ identifier: 5000123
diff --git a/config/samples/netbox_v1_l2vpnclaim.yaml b/config/samples/netbox_v1_l2vpnclaim.yaml
new file mode 100644
index 00000000..7c83df19
--- /dev/null
+++ b/config/samples/netbox_v1_l2vpnclaim.yaml
@@ -0,0 +1,15 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ labels:
+ app.kubernetes.io/name: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpnclaim-sample
+spec:
+ tenant: "Dunder-Mifflin, Inc."
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: true
+ type: "vxlan-evpn"
+ identifier: 5000123
diff --git a/config/samples/netbox_v1_l2vpnclaim_range.yaml b/config/samples/netbox_v1_l2vpnclaim_range.yaml
new file mode 100644
index 00000000..ac2a3b9b
--- /dev/null
+++ b/config/samples/netbox_v1_l2vpnclaim_range.yaml
@@ -0,0 +1,16 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ labels:
+ app.kubernetes.io/name: netbox-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: l2vpnclaim-range-sample
+spec:
+ tenant: "Dunder-Mifflin, Inc."
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: true
+ type: "vxlan-evpn"
+ identifierRangeStart: 4000
+ identifierRangeEnd: 16777215
diff --git a/gen/mock_interfaces/netbox_mocks.go b/gen/mock_interfaces/netbox_mocks.go
index c3bf755e..11afae07 100644
--- a/gen/mock_interfaces/netbox_mocks.go
+++ b/gen/mock_interfaces/netbox_mocks.go
@@ -1032,6 +1032,357 @@ func (mr *MockIpamAPIMockRecorder) IpamPrefixesUpdate(ctx, id any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesUpdate", reflect.TypeOf((*MockIpamAPI)(nil).IpamPrefixesUpdate), ctx, id)
}
+// MockVpnL2vpnsListRequest is a mock of VpnL2vpnsListRequest interface.
+type MockVpnL2vpnsListRequest struct {
+ ctrl *gomock.Controller
+ recorder *MockVpnL2vpnsListRequestMockRecorder
+ isgomock struct{}
+}
+
+// MockVpnL2vpnsListRequestMockRecorder is the mock recorder for MockVpnL2vpnsListRequest.
+type MockVpnL2vpnsListRequestMockRecorder struct {
+ mock *MockVpnL2vpnsListRequest
+}
+
+// NewMockVpnL2vpnsListRequest creates a new mock instance.
+func NewMockVpnL2vpnsListRequest(ctrl *gomock.Controller) *MockVpnL2vpnsListRequest {
+ mock := &MockVpnL2vpnsListRequest{ctrl: ctrl}
+ mock.recorder = &MockVpnL2vpnsListRequestMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVpnL2vpnsListRequest) EXPECT() *MockVpnL2vpnsListRequestMockRecorder {
+ return m.recorder
+}
+
+// Execute mocks base method.
+func (m *MockVpnL2vpnsListRequest) Execute() (*netbox.PaginatedL2VPNList, *http.Response, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Execute")
+ ret0, _ := ret[0].(*netbox.PaginatedL2VPNList)
+ ret1, _ := ret[1].(*http.Response)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// Execute indicates an expected call of Execute.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) Execute() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).Execute))
+}
+
+// IdentifierGte mocks base method.
+func (m *MockVpnL2vpnsListRequest) IdentifierGte(identifierGte []int32) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "IdentifierGte", identifierGte)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// IdentifierGte indicates an expected call of IdentifierGte.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) IdentifierGte(identifierGte any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IdentifierGte", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).IdentifierGte), identifierGte)
+}
+
+// IdentifierLte mocks base method.
+func (m *MockVpnL2vpnsListRequest) IdentifierLte(identifierLte []int32) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "IdentifierLte", identifierLte)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// IdentifierLte indicates an expected call of IdentifierLte.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) IdentifierLte(identifierLte any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IdentifierLte", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).IdentifierLte), identifierLte)
+}
+
+// Limit mocks base method.
+func (m *MockVpnL2vpnsListRequest) Limit(limit int32) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Limit", limit)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// Limit indicates an expected call of Limit.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) Limit(limit any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).Limit), limit)
+}
+
+// Name mocks base method.
+func (m *MockVpnL2vpnsListRequest) Name(name []string) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Name", name)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// Name indicates an expected call of Name.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) Name(name any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).Name), name)
+}
+
+// Offset mocks base method.
+func (m *MockVpnL2vpnsListRequest) Offset(offset int32) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Offset", offset)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// Offset indicates an expected call of Offset.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) Offset(offset any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Offset", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).Offset), offset)
+}
+
+// Type_ mocks base method.
+func (m *MockVpnL2vpnsListRequest) Type_(type_ []string) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Type_", type_)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// Type_ indicates an expected call of Type_.
+func (mr *MockVpnL2vpnsListRequestMockRecorder) Type_(type_ any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type_", reflect.TypeOf((*MockVpnL2vpnsListRequest)(nil).Type_), type_)
+}
+
+// MockVpnL2vpnsCreateRequest is a mock of VpnL2vpnsCreateRequest interface.
+type MockVpnL2vpnsCreateRequest struct {
+ ctrl *gomock.Controller
+ recorder *MockVpnL2vpnsCreateRequestMockRecorder
+ isgomock struct{}
+}
+
+// MockVpnL2vpnsCreateRequestMockRecorder is the mock recorder for MockVpnL2vpnsCreateRequest.
+type MockVpnL2vpnsCreateRequestMockRecorder struct {
+ mock *MockVpnL2vpnsCreateRequest
+}
+
+// NewMockVpnL2vpnsCreateRequest creates a new mock instance.
+func NewMockVpnL2vpnsCreateRequest(ctrl *gomock.Controller) *MockVpnL2vpnsCreateRequest {
+ mock := &MockVpnL2vpnsCreateRequest{ctrl: ctrl}
+ mock.recorder = &MockVpnL2vpnsCreateRequestMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVpnL2vpnsCreateRequest) EXPECT() *MockVpnL2vpnsCreateRequestMockRecorder {
+ return m.recorder
+}
+
+// Execute mocks base method.
+func (m *MockVpnL2vpnsCreateRequest) Execute() (*netbox.L2VPN, *http.Response, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Execute")
+ ret0, _ := ret[0].(*netbox.L2VPN)
+ ret1, _ := ret[1].(*http.Response)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// Execute indicates an expected call of Execute.
+func (mr *MockVpnL2vpnsCreateRequestMockRecorder) Execute() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockVpnL2vpnsCreateRequest)(nil).Execute))
+}
+
+// WritableL2VPNRequest mocks base method.
+func (m *MockVpnL2vpnsCreateRequest) WritableL2VPNRequest(writableL2VPNRequest netbox.WritableL2VPNRequest) interfaces.VpnL2vpnsCreateRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "WritableL2VPNRequest", writableL2VPNRequest)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsCreateRequest)
+ return ret0
+}
+
+// WritableL2VPNRequest indicates an expected call of WritableL2VPNRequest.
+func (mr *MockVpnL2vpnsCreateRequestMockRecorder) WritableL2VPNRequest(writableL2VPNRequest any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WritableL2VPNRequest", reflect.TypeOf((*MockVpnL2vpnsCreateRequest)(nil).WritableL2VPNRequest), writableL2VPNRequest)
+}
+
+// MockVpnL2vpnsUpdateRequest is a mock of VpnL2vpnsUpdateRequest interface.
+type MockVpnL2vpnsUpdateRequest struct {
+ ctrl *gomock.Controller
+ recorder *MockVpnL2vpnsUpdateRequestMockRecorder
+ isgomock struct{}
+}
+
+// MockVpnL2vpnsUpdateRequestMockRecorder is the mock recorder for MockVpnL2vpnsUpdateRequest.
+type MockVpnL2vpnsUpdateRequestMockRecorder struct {
+ mock *MockVpnL2vpnsUpdateRequest
+}
+
+// NewMockVpnL2vpnsUpdateRequest creates a new mock instance.
+func NewMockVpnL2vpnsUpdateRequest(ctrl *gomock.Controller) *MockVpnL2vpnsUpdateRequest {
+ mock := &MockVpnL2vpnsUpdateRequest{ctrl: ctrl}
+ mock.recorder = &MockVpnL2vpnsUpdateRequestMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVpnL2vpnsUpdateRequest) EXPECT() *MockVpnL2vpnsUpdateRequestMockRecorder {
+ return m.recorder
+}
+
+// Execute mocks base method.
+func (m *MockVpnL2vpnsUpdateRequest) Execute() (*netbox.L2VPN, *http.Response, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Execute")
+ ret0, _ := ret[0].(*netbox.L2VPN)
+ ret1, _ := ret[1].(*http.Response)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// Execute indicates an expected call of Execute.
+func (mr *MockVpnL2vpnsUpdateRequestMockRecorder) Execute() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockVpnL2vpnsUpdateRequest)(nil).Execute))
+}
+
+// WritableL2VPNRequest mocks base method.
+func (m *MockVpnL2vpnsUpdateRequest) WritableL2VPNRequest(writableL2VPNRequest netbox.WritableL2VPNRequest) interfaces.VpnL2vpnsUpdateRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "WritableL2VPNRequest", writableL2VPNRequest)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsUpdateRequest)
+ return ret0
+}
+
+// WritableL2VPNRequest indicates an expected call of WritableL2VPNRequest.
+func (mr *MockVpnL2vpnsUpdateRequestMockRecorder) WritableL2VPNRequest(writableL2VPNRequest any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WritableL2VPNRequest", reflect.TypeOf((*MockVpnL2vpnsUpdateRequest)(nil).WritableL2VPNRequest), writableL2VPNRequest)
+}
+
+// MockVpnL2vpnsDestroyRequest is a mock of VpnL2vpnsDestroyRequest interface.
+type MockVpnL2vpnsDestroyRequest struct {
+ ctrl *gomock.Controller
+ recorder *MockVpnL2vpnsDestroyRequestMockRecorder
+ isgomock struct{}
+}
+
+// MockVpnL2vpnsDestroyRequestMockRecorder is the mock recorder for MockVpnL2vpnsDestroyRequest.
+type MockVpnL2vpnsDestroyRequestMockRecorder struct {
+ mock *MockVpnL2vpnsDestroyRequest
+}
+
+// NewMockVpnL2vpnsDestroyRequest creates a new mock instance.
+func NewMockVpnL2vpnsDestroyRequest(ctrl *gomock.Controller) *MockVpnL2vpnsDestroyRequest {
+ mock := &MockVpnL2vpnsDestroyRequest{ctrl: ctrl}
+ mock.recorder = &MockVpnL2vpnsDestroyRequestMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVpnL2vpnsDestroyRequest) EXPECT() *MockVpnL2vpnsDestroyRequestMockRecorder {
+ return m.recorder
+}
+
+// Execute mocks base method.
+func (m *MockVpnL2vpnsDestroyRequest) Execute() (*http.Response, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Execute")
+ ret0, _ := ret[0].(*http.Response)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Execute indicates an expected call of Execute.
+func (mr *MockVpnL2vpnsDestroyRequestMockRecorder) Execute() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockVpnL2vpnsDestroyRequest)(nil).Execute))
+}
+
+// MockVpnAPI is a mock of VpnAPI interface.
+type MockVpnAPI struct {
+ ctrl *gomock.Controller
+ recorder *MockVpnAPIMockRecorder
+ isgomock struct{}
+}
+
+// MockVpnAPIMockRecorder is the mock recorder for MockVpnAPI.
+type MockVpnAPIMockRecorder struct {
+ mock *MockVpnAPI
+}
+
+// NewMockVpnAPI creates a new mock instance.
+func NewMockVpnAPI(ctrl *gomock.Controller) *MockVpnAPI {
+ mock := &MockVpnAPI{ctrl: ctrl}
+ mock.recorder = &MockVpnAPIMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVpnAPI) EXPECT() *MockVpnAPIMockRecorder {
+ return m.recorder
+}
+
+// VpnL2vpnsCreate mocks base method.
+func (m *MockVpnAPI) VpnL2vpnsCreate(ctx context.Context) interfaces.VpnL2vpnsCreateRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VpnL2vpnsCreate", ctx)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsCreateRequest)
+ return ret0
+}
+
+// VpnL2vpnsCreate indicates an expected call of VpnL2vpnsCreate.
+func (mr *MockVpnAPIMockRecorder) VpnL2vpnsCreate(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VpnL2vpnsCreate", reflect.TypeOf((*MockVpnAPI)(nil).VpnL2vpnsCreate), ctx)
+}
+
+// VpnL2vpnsDestroy mocks base method.
+func (m *MockVpnAPI) VpnL2vpnsDestroy(ctx context.Context, id int32) interfaces.VpnL2vpnsDestroyRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VpnL2vpnsDestroy", ctx, id)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsDestroyRequest)
+ return ret0
+}
+
+// VpnL2vpnsDestroy indicates an expected call of VpnL2vpnsDestroy.
+func (mr *MockVpnAPIMockRecorder) VpnL2vpnsDestroy(ctx, id any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VpnL2vpnsDestroy", reflect.TypeOf((*MockVpnAPI)(nil).VpnL2vpnsDestroy), ctx, id)
+}
+
+// VpnL2vpnsList mocks base method.
+func (m *MockVpnAPI) VpnL2vpnsList(ctx context.Context) interfaces.VpnL2vpnsListRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VpnL2vpnsList", ctx)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsListRequest)
+ return ret0
+}
+
+// VpnL2vpnsList indicates an expected call of VpnL2vpnsList.
+func (mr *MockVpnAPIMockRecorder) VpnL2vpnsList(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VpnL2vpnsList", reflect.TypeOf((*MockVpnAPI)(nil).VpnL2vpnsList), ctx)
+}
+
+// VpnL2vpnsUpdate mocks base method.
+func (m *MockVpnAPI) VpnL2vpnsUpdate(ctx context.Context, id int32) interfaces.VpnL2vpnsUpdateRequest {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VpnL2vpnsUpdate", ctx, id)
+ ret0, _ := ret[0].(interfaces.VpnL2vpnsUpdateRequest)
+ return ret0
+}
+
+// VpnL2vpnsUpdate indicates an expected call of VpnL2vpnsUpdate.
+func (mr *MockVpnAPIMockRecorder) VpnL2vpnsUpdate(ctx, id any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VpnL2vpnsUpdate", reflect.TypeOf((*MockVpnAPI)(nil).VpnL2vpnsUpdate), ctx, id)
+}
+
// MockAPIStatusRetrieveRequest is a mock of APIStatusRetrieveRequest interface.
type MockAPIStatusRetrieveRequest struct {
ctrl *gomock.Controller
diff --git a/internal/controller/l2vpn_controller.go b/internal/controller/l2vpn_controller.go
new file mode 100644
index 00000000..acee216f
--- /dev/null
+++ b/internal/controller/l2vpn_controller.go
@@ -0,0 +1,361 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/api"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+ "github.com/netbox-community/netbox-operator/pkg/scheduler"
+
+ "github.com/swisscom/leaselocker"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apismeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/rest"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+const L2VPNFinalizerName = "l2vpn.netbox.dev/finalizer"
+const L2VPNManagedCustomFieldsAnnotationName = "l2vpn.netbox.dev/managed-custom-fields"
+
+// L2VPNReconciler reconciles a L2VPN object
+type L2VPNReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ NetboxClient *api.NetboxCompositeClient
+ EventStatusRecorder *EventStatusRecorder
+ OperatorNamespace string
+ RestConfig *rest.Config
+}
+
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpns,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpns/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpns/finalizers,verbs=update
+//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+func (r *L2VPNReconciler) Reconcile(ctx context.Context, req ctrl.Request) (reconcileResult ctrl.Result, reconcileErr error) {
+ logger := log.FromContext(ctx)
+
+ logger.Info("reconcile loop started")
+
+ o := &netboxv1.L2VPN{}
+ err := r.Get(ctx, req.NamespacedName, o)
+ if err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // Snapshot for status patch — taken before any status mutations so the
+ // merge-patch diff captures every change (L2VPNId, conditions, etc.).
+ statusBase := o.DeepCopy()
+
+ // Defer status update to ensure it happens regardless of how we exit
+ defer func() {
+ reconcileResult, reconcileErr = r.updateStatus(ctx, o, statusBase, reconcileResult, reconcileErr)
+ if reconcileErr == nil && reconcileResult.IsZero() {
+ reconcileResult, reconcileErr = scheduler.CalculateNextReconcile(ctx)
+ }
+ logger.Info("reconcile loop finished")
+ }()
+
+ // if being deleted
+ if !o.DeletionTimestamp.IsZero() {
+ if !controllerutil.ContainsFinalizer(o, L2VPNFinalizerName) {
+ return ctrl.Result{}, nil
+ }
+
+ if !o.Spec.PreserveInNetbox {
+ if o.Status.L2VPNId > math.MaxInt32 {
+ return ctrl.Result{}, fmt.Errorf("reconciliation of l2vpns with id's larger than 2147483647 is not supported")
+ }
+ if err := r.NetboxClient.DeleteL2VPN(ctx, int32(o.Status.L2VPNId)); err != nil {
+ return ctrl.Result{}, NewDomainError("failed to delete l2vpn in netbox: %w", err)
+ }
+ }
+
+ return ctrl.Result{}, removeFinalizer(ctx, r.Client, o, L2VPNFinalizerName)
+ }
+
+ // if PreserveInNetbox flag is false then register finalizer if not yet registered
+ if !o.Spec.PreserveInNetbox {
+ err = addFinalizer(ctx, r.Client, o, L2VPNFinalizerName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ // 1. try to lock lease of the identifier range if L2VPN status condition is
+ // not true, is owned by a range-based L2VPNClaim, and hasn't been created
+ // in NetBox yet. Claims with an explicit identifier don't draw from a
+ // shared pool, so there's nothing to serialize against.
+ or := o.OwnerReferences
+ var ll *leaselocker.LeaseLocker
+ var cancelLock context.CancelFunc
+ if len(or) > 0 && !apismeta.IsStatusConditionTrue(o.Status.Conditions, "Ready") {
+ leaseLockerNSN, owner, rangeDesc, ok, err := r.getLeaseLockerNSNandOwner(ctx, o)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ if ok {
+ ll, err = leaselocker.NewLeaseLocker(r.RestConfig, leaseLockerNSN, owner)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ var lockCtx context.Context
+ lockCtx, cancelLock = context.WithTimeout(ctx, lockAcquireTimeout)
+ defer func() {
+ if cancelLock != nil {
+ cancelLock()
+ }
+ }()
+
+ // create lock
+ locked := ll.TryLock(lockCtx)
+ if !locked {
+ errorMsg := fmt.Sprintf("failed to lock identifier range %s", rangeDesc)
+ r.EventStatusRecorder.Recorder().Event(o, corev1.EventTypeWarning, "FailedToLockIdentifierRange", errorMsg)
+ return ctrl.Result{
+ RequeueAfter: 2 * time.Second,
+ }, NewDomainError("%s", errorMsg)
+ }
+ logger.V(4).Info(fmt.Sprintf("successfully locked identifier range %s", rangeDesc))
+ }
+ }
+
+ // 2. reserve or update l2vpn in netbox
+ accessor := apismeta.NewAccessor()
+ annotations, err := accessor.Annotations(o)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ l2vpnModel, err := r.generateNetboxL2VPNModelFromL2VPNSpec(o, req, annotations[L2VPNManagedCustomFieldsAnnotationName])
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ netboxL2VPNModel, statusUpToDate, err := r.NetboxClient.ReserveOrUpdateL2VPN(ctx, l2vpnModel, o)
+ if err != nil {
+ if errors.Is(err, api.ErrRestorationHashMismatch) && o.Status.L2VPNId == 0 {
+ logger.Info("conflict in claimed l2vpn, deleting l2vpn custom resource", "identifier",
+ o.Spec.Identifier, "error", err)
+ if deleteErr := r.Delete(ctx, o); deleteErr != nil {
+ return ctrl.Result{}, NewDomainError("failed to delete L2VPN CR with conflict: %w", deleteErr)
+ }
+ // Object deleted - status update in deferred function will be ignored via client.IgnoreNotFound
+ return ctrl.Result{}, nil
+ }
+
+ return ctrl.Result{}, NewDomainError("%w", err)
+ }
+
+ // 3. unlock lease of the identifier range
+ if ll != nil {
+ cancelLock()
+ ll.UnlockWithRetry(ctx)
+ }
+
+ // 4. if no change, then end loop
+ if statusUpToDate {
+ return ctrl.Result{}, nil
+ }
+
+ // 4.1 update annotation
+ if annotations == nil {
+ annotations = make(map[string]string, 1)
+ }
+
+ annotations[L2VPNManagedCustomFieldsAnnotationName], err = generateManagedCustomFieldsAnnotation(o.Spec.CustomFields)
+ if err != nil {
+ return ctrl.Result{}, NewDomainError("failed to generate managed custom fields annotation: %w", err)
+ }
+
+ // snapshot before annotation mutation for merge-patch
+ patch := client.MergeFrom(o.DeepCopy())
+
+ err = accessor.SetAnnotations(o, annotations)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // patch object to store managed custom fields annotation
+ err = r.Patch(ctx, o, patch)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // update status fields (set after r.Patch to avoid being overwritten by API response)
+ o.Status.L2VPNId = int64(netboxL2VPNModel.GetId())
+ o.Status.Slug = netboxL2VPNModel.GetSlug()
+ o.Status.L2VPNUrl = config.GetBaseUrl() + "/vpn/l2vpns/" + strconv.FormatInt(int64(netboxL2VPNModel.GetId()), 10)
+ if netboxL2VPNModel.LastUpdated.IsSet() {
+ o.Status.LastUpdated = metav1.NewTime(*netboxL2VPNModel.LastUpdated.Get())
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *L2VPNReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&netboxv1.L2VPN{}).
+ Complete(r)
+}
+
+// updateStatus updates the L2VPN status conditions based on the current state of the object.
+// This function is called as a deferred function in Reconcile to ensure status is always updated.
+func (r *L2VPNReconciler) updateStatus(ctx context.Context, o *netboxv1.L2VPN, statusBase *netboxv1.L2VPN, reconcileRes ctrl.Result, reconcileErr error) (result ctrl.Result, err error) {
+ logger := log.FromContext(ctx)
+
+ // Set default return values
+ result = reconcileRes
+ err = reconcileErr
+
+ if apierrors.IsConflict(err) {
+ // Object was modified concurrently — skip status update, will retry on requeue
+ return IgnoreDomainError(result, err)
+ }
+
+ logger.V(4).Info("updating l2vpn status")
+
+ switch {
+ case !o.DeletionTimestamp.IsZero() && reconcileErr != nil:
+ r.EventStatusRecorder.Report(ctx, o,
+ netboxv1.ConditionL2VPNReadyFalseDeletionFailed, corev1.EventTypeWarning, reconcileErr)
+ case !o.DeletionTimestamp.IsZero():
+ r.EventStatusRecorder.Report(ctx, o,
+ netboxv1.ConditionL2VPNReadyFalseDeletionInProgress, corev1.EventTypeNormal, nil)
+ case o.Status.L2VPNUrl == "":
+ r.EventStatusRecorder.Report(ctx, o,
+ netboxv1.ConditionL2VPNReadyFalse, corev1.EventTypeWarning, reconcileErr,
+ fmt.Sprintf("identifier: %d", o.Spec.Identifier))
+ case reconcileErr != nil:
+ r.EventStatusRecorder.Report(ctx, o,
+ netboxv1.ConditionL2VPNReadyFalse, corev1.EventTypeWarning, reconcileErr,
+ fmt.Sprintf("identifier: %d", o.Spec.Identifier))
+ default:
+ r.EventStatusRecorder.Report(ctx, o,
+ netboxv1.ConditionL2VPNReadyTrue, corev1.EventTypeNormal, nil)
+ }
+
+ // Align resource version so the patch targets the latest revision
+ statusBase.SetResourceVersion(o.GetResourceVersion())
+ statusPatch := client.MergeFrom(statusBase)
+ patchErr := r.Status().Patch(ctx, o, statusPatch)
+ if patchErr != nil {
+ patchErr = client.IgnoreNotFound(patchErr)
+ if patchErr != nil {
+ err = errors.Join(err, patchErr)
+ }
+ }
+
+ return IgnoreDomainError(result, err)
+}
+
+func (r *L2VPNReconciler) generateNetboxL2VPNModelFromL2VPNSpec(o *netboxv1.L2VPN, req ctrl.Request, lastL2VPNMetadata string) (*models.L2VPN, error) {
+ // unmarshal lastL2VPNMetadata json string to map[string]string
+ lastAppliedCustomFields := make(map[string]string)
+ if lastL2VPNMetadata != "" {
+ if err := json.Unmarshal([]byte(lastL2VPNMetadata), &lastAppliedCustomFields); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal lastL2VPNMetadata annotation: %w", err)
+ }
+ }
+
+ netboxCustomFields := make(map[string]string)
+ if len(o.Spec.CustomFields) > 0 {
+ netboxCustomFields = maps.Clone(o.Spec.CustomFields)
+ }
+
+ // if a custom field was removed from the spec, add it with an empty value
+ for key := range lastAppliedCustomFields {
+ _, ok := netboxCustomFields[key]
+ if !ok {
+ netboxCustomFields[key] = ""
+ }
+ }
+
+ description := api.TruncateDescription(req.String() + " // " + o.Spec.Description)
+
+ // check if created l2vpn contains entire description from spec
+ _, found := strings.CutPrefix(description, req.String()+" // "+o.Spec.Description)
+ if !found {
+ r.EventStatusRecorder.Recorder().Event(o, corev1.EventTypeWarning, "L2VPNDescriptionTruncated", "l2vpn was created with truncated description")
+ }
+
+ return &models.L2VPN{
+ Name: o.Spec.Name,
+ Type: o.Spec.Type,
+ Identifier: o.Spec.Identifier,
+ Metadata: &models.NetboxMetadata{
+ Comments: o.Spec.Comments,
+ Custom: netboxCustomFields,
+ Description: description,
+ Tenant: o.Spec.Tenant,
+ },
+ }, nil
+}
+
+// getLeaseLockerNSNandOwner returns the lease lock identity for the L2VPN's
+// owning claim's identifier range. ok is false when the owning claim uses an
+// explicit identifier instead of a range, in which case there's no shared
+// pool to lock.
+func (r *L2VPNReconciler) getLeaseLockerNSNandOwner(ctx context.Context, o *netboxv1.L2VPN) (nsn types.NamespacedName, owner string, rangeDesc string, ok bool, err error) {
+ orLookupKey := types.NamespacedName{
+ Name: o.ObjectMeta.OwnerReferences[0].Name,
+ Namespace: o.Namespace,
+ }
+
+ l2vpnClaim := &netboxv1.L2VPNClaim{}
+ err = r.Get(ctx, orLookupKey, l2vpnClaim)
+ if err != nil {
+ return types.NamespacedName{}, "", "", false, err
+ }
+
+ if l2vpnClaim.Spec.IdentifierRangeStart == 0 && l2vpnClaim.Spec.IdentifierRangeEnd == 0 {
+ return types.NamespacedName{}, "", "", false, nil
+ }
+
+ rangeDesc = fmt.Sprintf("%s:%d-%d", l2vpnClaim.Spec.Type, l2vpnClaim.Spec.IdentifierRangeStart, l2vpnClaim.Spec.IdentifierRangeEnd)
+
+ leaseLockerNSN := types.NamespacedName{
+ Name: convertL2VPNRangeToLeaseLockName(l2vpnClaim.Spec.Type, l2vpnClaim.Spec.IdentifierRangeStart, l2vpnClaim.Spec.IdentifierRangeEnd),
+ Namespace: r.OperatorNamespace,
+ }
+
+ return leaseLockerNSN, orLookupKey.String(), rangeDesc, true, nil
+}
diff --git a/internal/controller/l2vpn_controller_test.go b/internal/controller/l2vpn_controller_test.go
new file mode 100644
index 00000000..5eec67a1
--- /dev/null
+++ b/internal/controller/l2vpn_controller_test.go
@@ -0,0 +1,319 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/gen/mock_interfaces"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apismeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/tools/record"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+var _ = Describe("L2VPN Controller", Ordered, func() {
+
+ const timeout = time.Second * 4
+ const interval = time.Millisecond * 250
+
+ var unexpectedCallCh chan error
+
+ BeforeEach(func() {
+ unexpectedCallCh = make(chan error)
+ })
+
+ AfterEach(func() {
+ By("Resetting the mock controller")
+ resetVpnMockFunctions()
+ })
+
+ DescribeTable("Reconciler (l2vpn CR without owner reference)", func(
+ cr *netboxv1.L2VPN, // our CR as typed object
+ VpnAPIMocks []func(*mock_interfaces.MockVpnAPI, chan error),
+ VpnListRequestMocks []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error),
+ VpnCreateRequestMocks []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error),
+ VpnUpdateRequestMocks []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error),
+ VpnDestroyRequestMocks []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error),
+ restorationHashMismatch bool, // To check for deletion if restoration hash does not match
+ expectedConditionReady metav1.Condition, // Expected state of the ConditionReady condition
+ expectedCRStatus netboxv1.L2VPNStatus, // Expected status of the CR
+ ) {
+ By("Setting up mocks")
+ for _, mock := range VpnAPIMocks {
+ mock(mockVpnAPI, unexpectedCallCh)
+ }
+ for _, mock := range VpnListRequestMocks {
+ mock(mockVpnListRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnCreateRequestMocks {
+ mock(mockVpnCreateRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnUpdateRequestMocks {
+ mock(mockVpnUpdateRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnDestroyRequestMocks {
+ mock(mockVpnDestroyRequest, unexpectedCallCh)
+ }
+
+ catchCtx, catchCtxCancel := context.WithCancel(context.Background())
+ defer catchCtxCancel()
+
+ // Goroutine to monitor mock calls with unexpected parameters
+ go func() {
+ defer GinkgoRecover()
+ select {
+ case errMsg := <-unexpectedCallCh:
+ Fail(errMsg.Error())
+
+ case <-catchCtx.Done():
+ // Context was cancelled
+ }
+ }()
+
+ // Create our CR
+ By("Creating L2VPN CR")
+ Eventually(k8sClient.Create(ctx, cr), timeout, interval).Should(Succeed())
+
+ createdCR := &netboxv1.L2VPN{}
+
+ if restorationHashMismatch {
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return apierrors.IsNotFound(err)
+ }, timeout, interval).Should(BeTrue())
+ } else {
+
+ // check that reconcile loop did run a least once by checking that conditions are set
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return err == nil && len(createdCR.Status.Conditions) > 0
+ }, timeout, interval).Should(BeTrue())
+
+ // Now check if conditions are set as expected
+ Eventually(func(g Gomega) {
+ g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)).To(Succeed())
+ cond := apismeta.FindStatusCondition(createdCR.Status.Conditions, expectedConditionReady.Type)
+ g.Expect(cond).NotTo(BeNil())
+ g.Expect(cond.Status).To(Equal(expectedConditionReady.Status))
+ g.Expect(cond.Reason).To(Equal(expectedConditionReady.Reason))
+ }, timeout, interval).Should(Succeed())
+
+ // Check that the expected l2vpn id/slug is present in the status
+ Expect(createdCR.Status.L2VPNId).To(Equal(expectedCRStatus.L2VPNId))
+ Expect(createdCR.Status.Slug).To(Equal(expectedCRStatus.Slug))
+
+ // Cleanup the netbox resources
+ Expect(k8sClient.Delete(ctx, createdCR)).Should(Succeed())
+
+ // Wait until the resource is deleted to make sure that it will not interfere with the next test case
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return err != client.IgnoreNotFound(err)
+ }, timeout, interval).Should(BeTrue())
+ }
+
+ catchCtxCancel()
+ },
+ Entry("Create L2VPN CR, reserve new l2vpn in NetBox",
+ defaultL2VPNCR(false),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPICreate,
+ mockVpnAPIDestroy,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByName,
+ mockVpnListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){
+ mockVpnCreateRequestBuild,
+ mockVpnCreateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteSuccess,
+ },
+ false, netboxv1.ConditionL2VPNReadyTrue, ExpectedL2VPNStatus),
+ Entry("Create L2VPN CR, l2vpn already reserved in NetBox, preserved in netbox",
+ defaultL2VPNCR(true),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPIUpdate,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByName,
+ mockVpnListRequestExecuteExistingNoHash,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){
+ mockVpnUpdateRequestBuild,
+ mockVpnUpdateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){},
+ false, netboxv1.ConditionL2VPNReadyTrue, ExpectedL2VPNStatus),
+ Entry("Create L2VPN CR, l2vpn already reserved in NetBox",
+ defaultL2VPNCR(false),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPIUpdate,
+ mockVpnAPIDestroy,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByName,
+ mockVpnListRequestExecuteExistingNoHash,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){
+ mockVpnUpdateRequestBuild,
+ mockVpnUpdateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteSuccess,
+ },
+ false, netboxv1.ConditionL2VPNReadyTrue, ExpectedL2VPNStatus),
+ Entry("Create L2VPN CR, reserve failure",
+ defaultL2VPNCR(false),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPICreate,
+ mockVpnAPIDestroyZeroId,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByName,
+ mockVpnListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){
+ mockVpnCreateRequestBuild,
+ mockVpnCreateRequestExecuteFail,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteNotFound,
+ },
+ false, netboxv1.ConditionL2VPNReadyFalse, netboxv1.L2VPNStatus{}),
+ Entry("Create L2VPN CR, restoration hash mismatch",
+ defaultL2VPNCreatedByClaim(true),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByName,
+ mockVpnListRequestExecuteExistingWithMismatchedHash,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){},
+ true, metav1.Condition{}, netboxv1.L2VPNStatus{}),
+ )
+})
+
+var _ = Describe("L2VPN updateStatus", func() {
+ newStatusTestObject := func() (*netboxv1.L2VPN, *netboxv1.L2VPN) {
+ obj := &netboxv1.L2VPN{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "status-test",
+ Namespace: "default",
+ },
+ }
+ return obj, obj.DeepCopy()
+ }
+
+ newStatusTestReconciler := func(obj *netboxv1.L2VPN, patchErr error) *L2VPNReconciler {
+ baseClient := fake.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithStatusSubresource(obj.DeepCopy()).
+ WithObjects(obj.DeepCopy()).
+ Build()
+
+ return &L2VPNReconciler{
+ Client: &statusPatchInterceptClient{
+ Client: baseClient,
+ statusWriter: &statusPatchInterceptWriter{
+ SubResourceWriter: baseClient.Status(),
+ patchErr: patchErr,
+ },
+ },
+ EventStatusRecorder: NewEventStatusRecorder(record.NewFakeRecorder(10)),
+ }
+ }
+
+ It("requeues without returning the domain error when the status patch succeeds", func() {
+ obj, statusBase := newStatusTestObject()
+ reconciler := newStatusTestReconciler(obj, nil)
+
+ result, err := reconciler.updateStatus(context.Background(), obj, statusBase, ctrl.Result{}, NewDomainError("reserve failed"))
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).To(Equal(ctrl.Result{Requeue: true}))
+
+ cond := apismeta.FindStatusCondition(obj.Status.Conditions, netboxv1.ConditionL2VPNReadyFalse.Type)
+ Expect(cond).NotTo(BeNil())
+ Expect(cond.Message).To(ContainSubstring("reserve failed"))
+ })
+
+ It("ignores a not found status patch error after a domain error", func() {
+ obj, statusBase := newStatusTestObject()
+ notFoundErr := apierrors.NewNotFound(schema.GroupResource{Group: "netbox.dev", Resource: "l2vpns"}, obj.Name)
+ reconciler := newStatusTestReconciler(obj, notFoundErr)
+
+ result, err := reconciler.updateStatus(context.Background(), obj, statusBase, ctrl.Result{}, NewDomainError("reserve failed"))
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).To(Equal(ctrl.Result{Requeue: true}))
+ })
+
+ It("returns only the later patch error when it happens after a domain error", func() {
+ obj, statusBase := newStatusTestObject()
+ patchErr := errors.New("status patch failed")
+ reconciler := newStatusTestReconciler(obj, patchErr)
+
+ result, err := reconciler.updateStatus(context.Background(), obj, statusBase, ctrl.Result{}, NewDomainError("reserve failed"))
+
+ Expect(result).To(Equal(ctrl.Result{}))
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, patchErr)).To(BeTrue())
+
+ var domainErr *DomainError
+ Expect(errors.As(err, &domainErr)).To(BeFalse())
+ })
+
+ It("keeps both non-domain errors when reconcile and patch both fail", func() {
+ obj, statusBase := newStatusTestObject()
+ reconcileErr := errors.New("reconcile failed")
+ patchErr := errors.New("status patch failed")
+ reconciler := newStatusTestReconciler(obj, patchErr)
+
+ result, err := reconciler.updateStatus(context.Background(), obj, statusBase, ctrl.Result{}, reconcileErr)
+
+ Expect(result).To(Equal(ctrl.Result{}))
+ Expect(err).To(HaveOccurred())
+ Expect(errors.Is(err, reconcileErr)).To(BeTrue())
+ Expect(errors.Is(err, patchErr)).To(BeTrue())
+ })
+})
diff --git a/internal/controller/l2vpn_netboxmock_calls_test.go b/internal/controller/l2vpn_netboxmock_calls_test.go
new file mode 100644
index 00000000..27f28831
--- /dev/null
+++ b/internal/controller/l2vpn_netboxmock_calls_test.go
@@ -0,0 +1,195 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "fmt"
+ "net/http"
+
+ v4client "github.com/netbox-community/go-netbox/v4"
+ "github.com/netbox-community/netbox-operator/gen/mock_interfaces"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ "go.uber.org/mock/gomock"
+)
+
+// -----------------------------
+// VpnAPI mock functions (L2VPNReconciler side: mockVpnAPI/mockVpnListRequest/
+// mockVpnCreateRequest/mockVpnUpdateRequest/mockVpnDestroyRequest)
+// -----------------------------
+
+func mockVpnAPIList(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockVpnListRequest).MinTimes(1)
+}
+
+func mockVpnAPICreate(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsCreate(gomock.Any()).Return(mockVpnCreateRequest).MinTimes(1)
+}
+
+func mockVpnAPIUpdate(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsUpdate(gomock.Any(), l2vpnId).Return(mockVpnUpdateRequest).MinTimes(1)
+}
+
+func mockVpnAPIDestroy(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsDestroy(gomock.Any(), l2vpnId).Return(mockVpnDestroyRequest).MinTimes(1)
+}
+
+// mockVpnAPIDestroyZeroId matches the cleanup delete call issued when the CR
+// is deleted after NetBox reservation never succeeded (o.Status.L2VPNId==0).
+func mockVpnAPIDestroyZeroId(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsDestroy(gomock.Any(), int32(0)).Return(mockVpnDestroyRequest).MinTimes(1)
+}
+
+func mockVpnListRequestByName(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Name([]string{l2vpnName}).Return(mockVpnListRequest).MinTimes(1)
+}
+
+func mockVpnListRequestExecuteEmpty(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNListEmpty(), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnListRequestExecuteExistingNoHash(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNListExisting(nil), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnListRequestExecuteExistingWithMismatchedHash(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNListExisting(l2vpnCustomFieldsWithHashMismatchNetboxFmt), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+// mockVpnListRequestByClaimName matches the name-based lookup L2VPNReconciler
+// issues for a child L2VPN CR created by a L2VPNClaim (CR name == claim name).
+func mockVpnListRequestByClaimName(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Name([]string{l2vpnClaimName}).Return(mockVpnListRequest).MinTimes(1)
+}
+
+// mockVpnListRequestExecuteClaimExistingWithHash returns a mock for
+// L2VPNReconciler's name-based lookup finding a pre-existing NetBox object
+// (named after the claim) carrying the given restoration hash/identifier.
+func mockVpnListRequestExecuteClaimExistingWithHash(hash string, identifier int64) func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error) {
+ return func(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ lastUpdated := l2vpnLastUpdated
+ existing := v4client.L2VPN{
+ Id: l2vpnId,
+ Name: l2vpnClaimName,
+ Slug: l2vpnSlug,
+ CustomFields: map[string]interface{}{
+ config.GetOperatorConfig().NetboxRestorationHashFieldName: hash,
+ },
+ }
+ existing.LastUpdated = *v4client.NewNullableTime(&lastUpdated)
+ existing.Identifier = *v4client.NewNullableInt64(&identifier)
+ reqMock.EXPECT().Execute().
+ Return(&v4client.PaginatedL2VPNList{Count: 1, Results: []v4client.L2VPN{existing}}, &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+ }
+}
+
+func mockVpnCreateRequestBuild(reqMock *mock_interfaces.MockVpnL2vpnsCreateRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().WritableL2VPNRequest(gomock.Any()).Return(mockVpnCreateRequest).MinTimes(1)
+}
+
+func mockVpnCreateRequestExecuteSuccess(reqMock *mock_interfaces.MockVpnL2vpnsCreateRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNResponse(), &http.Response{StatusCode: 201, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnCreateRequestExecuteFail(reqMock *mock_interfaces.MockVpnL2vpnsCreateRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return((*v4client.L2VPN)(nil), &http.Response{StatusCode: 500, Body: http.NoBody}, fmt.Errorf("mock error in netbox")).
+ MinTimes(1)
+}
+
+func mockVpnUpdateRequestBuild(reqMock *mock_interfaces.MockVpnL2vpnsUpdateRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().WritableL2VPNRequest(gomock.Any()).Return(mockVpnUpdateRequest).MinTimes(1)
+}
+
+func mockVpnUpdateRequestExecuteSuccess(reqMock *mock_interfaces.MockVpnL2vpnsUpdateRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNResponse(), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnDestroyRequestExecuteSuccess(reqMock *mock_interfaces.MockVpnL2vpnsDestroyRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(&http.Response{StatusCode: 204, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnDestroyRequestExecuteNotFound(reqMock *mock_interfaces.MockVpnL2vpnsDestroyRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(&http.Response{StatusCode: 404, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+// -----------------------------
+// VpnAPI mock functions (L2VPNClaimReconciler side: mockVpnAPIClaim/
+// mockVpnClaimListRequest). Only ever exercises VpnL2vpnsList via
+// forEachL2VPN (Limit/Offset paging), never Name/Create/Update/Destroy.
+// -----------------------------
+
+func mockVpnAPIClaimList(vpnAPIMock *mock_interfaces.MockVpnAPI, catchUnexpectedParams chan error) {
+ vpnAPIMock.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockVpnClaimListRequest).MinTimes(1)
+}
+
+func mockVpnClaimListRequestPaging(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Limit(gomock.Any()).Return(mockVpnClaimListRequest).MinTimes(1)
+ reqMock.EXPECT().Offset(gomock.Any()).Return(mockVpnClaimListRequest).MinTimes(1)
+}
+
+func mockVpnClaimListRequestExecuteEmpty(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNListEmpty(), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+func mockVpnClaimListRequestExecuteWithMatchingHash(reqMock *mock_interfaces.MockVpnL2vpnsListRequest, catchUnexpectedParams chan error) {
+ hash := generateL2VPNRestorationHash(defaultL2VPNClaimCRWithRange())
+ reqMock.EXPECT().Execute().
+ Return(mockedL2VPNListWithHash(hash, l2vpnRestoredIdentifier), &http.Response{StatusCode: 200, Body: http.NoBody}, nil).
+ MinTimes(1)
+}
+
+// -----------------------------
+// Reset mock functions
+// -----------------------------
+
+func resetVpnMockFunctions() {
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Times(0)
+ mockVpnAPI.EXPECT().VpnL2vpnsCreate(gomock.Any()).Times(0)
+ mockVpnAPI.EXPECT().VpnL2vpnsUpdate(gomock.Any(), gomock.Any()).Times(0)
+ mockVpnAPI.EXPECT().VpnL2vpnsDestroy(gomock.Any(), gomock.Any()).Times(0)
+ mockVpnListRequest.EXPECT().Name(gomock.Any()).Times(0)
+ mockVpnListRequest.EXPECT().Limit(gomock.Any()).Times(0)
+ mockVpnListRequest.EXPECT().Offset(gomock.Any()).Times(0)
+ mockVpnListRequest.EXPECT().Execute().Times(0)
+ mockVpnCreateRequest.EXPECT().WritableL2VPNRequest(gomock.Any()).Times(0)
+ mockVpnCreateRequest.EXPECT().Execute().Times(0)
+ mockVpnUpdateRequest.EXPECT().WritableL2VPNRequest(gomock.Any()).Times(0)
+ mockVpnUpdateRequest.EXPECT().Execute().Times(0)
+ mockVpnDestroyRequest.EXPECT().Execute().Times(0)
+
+ mockVpnAPIClaim.EXPECT().VpnL2vpnsList(gomock.Any()).Times(0)
+ mockVpnClaimListRequest.EXPECT().Limit(gomock.Any()).Times(0)
+ mockVpnClaimListRequest.EXPECT().Offset(gomock.Any()).Times(0)
+ mockVpnClaimListRequest.EXPECT().Execute().Times(0)
+}
diff --git a/internal/controller/l2vpn_testdata_test.go b/internal/controller/l2vpn_testdata_test.go
new file mode 100644
index 00000000..3430b863
--- /dev/null
+++ b/internal/controller/l2vpn_testdata_test.go
@@ -0,0 +1,211 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "time"
+
+ v4client "github.com/netbox-community/go-netbox/v4"
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// -----------------------------
+// default values for L2VPN/L2VPNClaim CRs
+//
+// Tenant is intentionally left unset on every fixture below: setting it would
+// route ReserveOrUpdateL2VPN/GetAvailableL2VPNIdentifierByClaim through
+// getTenantDetails (clientV3.Tenancy), which these controller-level tests
+// don't wire up. Tenant resolution is already covered at the client level by
+// pkg/netbox/api/l2vpn_test.go.
+// -----------------------------
+
+var l2vpnName = "l2vpn-test"
+var l2vpnClaimName = "l2vpnclaim-test"
+var l2vpnNamespace = "default"
+var l2vpnType = "vxlan-evpn"
+var l2vpnIdentifier = int64(5000100)
+var l2vpnRestoredIdentifier = int64(5000150)
+var l2vpnRangeStart = int64(5000200)
+var l2vpnRangeEnd = int64(5000202)
+var l2vpnComments = "l2vpn integration test comment"
+var l2vpnDescription = "l2vpn integration test"
+var l2vpnCustomFields = map[string]string{"example_field": "example value"}
+var l2vpnId = int32(42)
+var l2vpnSlug = "l2vpn-test-slug"
+var l2vpnLastUpdated = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+
+var l2vpnRestorationHash = "6f6c67651f0b43b2969ba2ae35c74fc91815513a"
+var l2vpnCustomFieldsWithHash = map[string]string{"example_field": "example value", "netboxOperatorRestorationHash": l2vpnRestorationHash}
+var l2vpnCustomFieldsWithHashMismatchNetboxFmt = map[string]interface{}{"example_field": "example value", "netboxOperatorRestorationHash": "a-different-hash"}
+
+// -----------------------------
+// default CRs
+// -----------------------------
+
+func defaultL2VPNCR(preserveInNetbox bool) *netboxv1.L2VPN {
+ return &netboxv1.L2VPN{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: l2vpnName,
+ Namespace: l2vpnNamespace,
+ },
+ Spec: netboxv1.L2VPNSpec{
+ Name: l2vpnName,
+ Type: l2vpnType,
+ Identifier: l2vpnIdentifier,
+ CustomFields: l2vpnCustomFields,
+ Comments: l2vpnComments,
+ Description: l2vpnDescription,
+ PreserveInNetbox: preserveInNetbox,
+ },
+ }
+}
+
+// defaultL2VPNCreatedByClaim mimics a L2VPN CR that a L2VPNClaim controller
+// would have created: its CustomFields carry the restoration hash key, as
+// generateL2VPNSpec injects on every child L2VPN CR.
+func defaultL2VPNCreatedByClaim(preserveInNetbox bool) *netboxv1.L2VPN {
+ return &netboxv1.L2VPN{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: l2vpnName,
+ Namespace: l2vpnNamespace,
+ },
+ Spec: netboxv1.L2VPNSpec{
+ Name: l2vpnName,
+ Type: l2vpnType,
+ Identifier: l2vpnIdentifier,
+ CustomFields: l2vpnCustomFieldsWithHash,
+ Comments: l2vpnComments,
+ Description: l2vpnDescription,
+ PreserveInNetbox: preserveInNetbox,
+ },
+ }
+}
+
+func defaultL2VPNClaimCRWithIdentifier() *netboxv1.L2VPNClaim {
+ return &netboxv1.L2VPNClaim{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: l2vpnClaimName,
+ Namespace: l2vpnNamespace,
+ },
+ Spec: netboxv1.L2VPNClaimSpec{
+ Type: l2vpnType,
+ Identifier: l2vpnIdentifier,
+ CustomFields: l2vpnCustomFields,
+ Comments: l2vpnComments,
+ Description: l2vpnDescription,
+ PreserveInNetbox: false,
+ },
+ }
+}
+
+func defaultL2VPNClaimCRWithRange() *netboxv1.L2VPNClaim {
+ return &netboxv1.L2VPNClaim{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: l2vpnClaimName,
+ Namespace: l2vpnNamespace,
+ },
+ Spec: netboxv1.L2VPNClaimSpec{
+ Type: l2vpnType,
+ IdentifierRangeStart: l2vpnRangeStart,
+ IdentifierRangeEnd: l2vpnRangeEnd,
+ CustomFields: l2vpnCustomFields,
+ Comments: l2vpnComments,
+ Description: l2vpnDescription,
+ PreserveInNetbox: false,
+ },
+ }
+}
+
+// expectedL2VPNSpecFromClaim mirrors generateL2VPNSpec: the L2VPNClaim
+// controller copies the claim's mutable fields onto the child L2VPN CR and
+// injects the restoration hash custom field.
+func expectedL2VPNSpecFromClaim(claim *netboxv1.L2VPNClaim, identifier int64) netboxv1.L2VPNSpec {
+ customFields := make(map[string]string, len(claim.Spec.CustomFields)+1)
+ for k, v := range claim.Spec.CustomFields {
+ customFields[k] = v
+ }
+ customFields[config.GetOperatorConfig().NetboxRestorationHashFieldName] = generateL2VPNRestorationHash(claim)
+
+ return netboxv1.L2VPNSpec{
+ Name: claim.Name,
+ Type: claim.Spec.Type,
+ Identifier: identifier,
+ Tenant: claim.Spec.Tenant,
+ CustomFields: customFields,
+ Description: claim.Spec.Description,
+ Comments: claim.Spec.Comments,
+ PreserveInNetbox: claim.Spec.PreserveInNetbox,
+ }
+}
+
+// -----------------------------
+// netbox mock responses
+// -----------------------------
+
+func mockedL2VPNResponse() *v4client.L2VPN {
+ lastUpdated := l2vpnLastUpdated
+ resp := &v4client.L2VPN{
+ Id: l2vpnId,
+ Name: l2vpnName,
+ Slug: l2vpnSlug,
+ }
+ resp.LastUpdated = *v4client.NewNullableTime(&lastUpdated)
+ resp.Identifier = *v4client.NewNullableInt64(&l2vpnIdentifier)
+ return resp
+}
+
+func mockedL2VPNListEmpty() *v4client.PaginatedL2VPNList {
+ return &v4client.PaginatedL2VPNList{Count: 0, Results: []v4client.L2VPN{}}
+}
+
+// mockedL2VPNListExisting returns a single-page listing containing one L2VPN
+// matching l2vpnName, as found by L2VPNReconciler.getL2VPN (Name filter).
+// customFields is nil for a plain pre-existing NetBox object, or set with the
+// restoration hash key for objects claimed by a L2VPNClaim.
+func mockedL2VPNListExisting(customFields map[string]interface{}) *v4client.PaginatedL2VPNList {
+ lastUpdated := l2vpnLastUpdated
+ existing := v4client.L2VPN{
+ Id: l2vpnId,
+ Name: l2vpnName,
+ Slug: l2vpnSlug,
+ CustomFields: customFields,
+ }
+ existing.LastUpdated = *v4client.NewNullableTime(&lastUpdated)
+ existing.Identifier = *v4client.NewNullableInt64(&l2vpnIdentifier)
+ return &v4client.PaginatedL2VPNList{Count: 1, Results: []v4client.L2VPN{existing}}
+}
+
+// mockedL2VPNListWithHash returns a single-page listing (as scanned by
+// forEachL2VPN) containing one L2VPN carrying the given restoration hash, as
+// used by RestoreExistingL2VPNByHash.
+func mockedL2VPNListWithHash(hash string, identifier int64) *v4client.PaginatedL2VPNList {
+ l2vpn := v4client.L2VPN{
+ Name: l2vpnName,
+ CustomFields: map[string]interface{}{
+ config.GetOperatorConfig().NetboxRestorationHashFieldName: hash,
+ },
+ }
+ l2vpn.Identifier = *v4client.NewNullableInt64(&identifier)
+ return &v4client.PaginatedL2VPNList{Count: 1, Results: []v4client.L2VPN{l2vpn}}
+}
+
+var ExpectedL2VPNStatus = netboxv1.L2VPNStatus{
+ L2VPNId: int64(l2vpnId),
+ Slug: l2vpnSlug,
+}
diff --git a/internal/controller/l2vpnclaim_controller.go b/internal/controller/l2vpnclaim_controller.go
new file mode 100644
index 00000000..94758817
--- /dev/null
+++ b/internal/controller/l2vpnclaim_controller.go
@@ -0,0 +1,326 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/api"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+ "github.com/netbox-community/netbox-operator/pkg/scheduler"
+
+ "github.com/swisscom/leaselocker"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apismeta "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/rest"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+const L2VPNClaimFinalizerName = "l2vpnclaim.netbox.dev/finalizer"
+
+// L2VPNClaimReconciler reconciles a L2VPNClaim object
+type L2VPNClaimReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ NetboxClient *api.NetboxCompositeClient
+ EventStatusRecorder *EventStatusRecorder
+ OperatorNamespace string
+ RestConfig *rest.Config
+}
+
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpnclaims,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpnclaims/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=netbox.dev,resources=l2vpnclaims/finalizers,verbs=update
+//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+func (r *L2VPNClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (reconcileResult ctrl.Result, reconcileErr error) {
+ logger := log.FromContext(ctx)
+
+ logger.Info("reconcile loop started")
+
+ o := &netboxv1.L2VPNClaim{}
+ err := r.Get(ctx, req.NamespacedName, o)
+ if err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // Snapshot for status patch — taken before any status mutations so the
+ // merge-patch diff captures every change.
+ statusBase := o.DeepCopy()
+
+ l2vpn := &netboxv1.L2VPN{}
+ l2vpnLookupKey := types.NamespacedName{
+ Name: o.Name,
+ Namespace: o.Namespace,
+ }
+
+ // if being deleted
+ if !o.DeletionTimestamp.IsZero() {
+ err = r.Get(ctx, l2vpnLookupKey, l2vpn)
+ if err != nil {
+ if !apierrors.IsNotFound(err) {
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, removeFinalizer(ctx, r.Client, o, L2VPNClaimFinalizerName)
+ }
+
+ if err = r.Delete(ctx, l2vpn); err != nil && !apierrors.IsNotFound(err) {
+ return ctrl.Result{}, err
+ }
+
+ // requeue if owned l2vpn was still found
+ return ctrl.Result{Requeue: true}, nil
+ }
+
+ // Defer status update to ensure it happens regardless of how we exit
+ defer func() {
+ reconcileResult, reconcileErr = r.updateStatus(ctx, o, statusBase, l2vpnLookupKey, reconcileResult, reconcileErr)
+ if reconcileErr == nil && reconcileResult.IsZero() {
+ reconcileResult, reconcileErr = scheduler.CalculateNextReconcile(ctx)
+ }
+ logger.Info("reconcile loop finished")
+ }()
+
+ err = r.Get(ctx, l2vpnLookupKey, l2vpn)
+ if err != nil {
+ // return error if not a notfound error
+ if !apierrors.IsNotFound(err) {
+ return ctrl.Result{}, err
+ }
+
+ logger.V(4).Info("l2vpn object matching l2vpn claim was not found, creating new l2vpn object")
+
+ identifier, cancelLock, res, err := r.restoreOrAssignL2VPNAndSetCondition(ctx, o)
+ if cancelLock != nil {
+ defer cancelLock()
+ }
+ if identifier == nil {
+ return res, err
+ }
+
+ // create the L2VPN CR
+ l2vpnResource := generateL2VPNFromL2VPNClaim(ctx, o, *identifier)
+ err = controllerutil.SetControllerReference(o, l2vpnResource, r.Scheme)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ err = addFinalizer(ctx, r.Client, o, L2VPNClaimFinalizerName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ err = r.Create(ctx, l2vpnResource)
+ if err != nil {
+ return ctrl.Result{}, NewDomainError("failed to create L2VPN: %w", err)
+ }
+ } else {
+ // update spec of L2VPN object
+ logger.V(4).Info("update l2vpn resource")
+ l2vpn.Spec = generateL2VPNSpec(o, l2vpn.Spec.Identifier, logger)
+ err = controllerutil.SetControllerReference(o, l2vpn, r.Scheme)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ err = r.Update(ctx, l2vpn)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *L2VPNClaimReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&netboxv1.L2VPNClaim{}).
+ Owns(&netboxv1.L2VPN{}).
+ Complete(r)
+}
+
+// updateStatus updates the L2VPNClaim status based on the current state of the owned L2VPN.
+// This function is called as a deferred function in Reconcile to ensure status is always updated.
+func (r *L2VPNClaimReconciler) updateStatus(ctx context.Context, claim *netboxv1.L2VPNClaim, statusBase *netboxv1.L2VPNClaim, lookupKey types.NamespacedName, reconcileRes ctrl.Result, reconcileErr error) (result ctrl.Result, err error) {
+ logger := log.FromContext(ctx)
+
+ // Set default return values
+ result = reconcileRes
+ err = reconcileErr
+
+ // Ensure status update is always called, even on early returns
+ defer func() {
+ if apierrors.IsConflict(err) {
+ // Object was modified concurrently — skip status update, will retry on requeue
+ result, err = IgnoreDomainError(result, err)
+ return
+ }
+ // Align resource version so the patch targets the latest revision
+ statusBase.SetResourceVersion(claim.GetResourceVersion())
+ statusPatch := client.MergeFrom(statusBase)
+ patchErr := r.Status().Patch(ctx, claim, statusPatch)
+ if patchErr != nil {
+ patchErr = client.IgnoreNotFound(patchErr)
+ if patchErr != nil {
+ err = errors.Join(err, patchErr)
+ }
+ }
+ result, err = IgnoreDomainError(result, err)
+ }()
+
+ logger.V(4).Info("updating l2vpnclaim status")
+
+ // Fetch the latest L2VPN object
+ l2vpn := &netboxv1.L2VPN{}
+ err = r.Client.Get(ctx, lookupKey, l2vpn)
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ // L2VPN doesn't exist yet
+ r.EventStatusRecorder.Report(ctx, claim, netboxv1.ConditionL2VPNAssignedFalse, corev1.EventTypeWarning, reconcileErr)
+ r.EventStatusRecorder.Report(ctx, claim, netboxv1.ConditionL2VPNClaimReadyFalse, corev1.EventTypeWarning, reconcileErr)
+ // Preserve original result (e.g. RequeueAfter from lock contention)
+ if result.IsZero() {
+ result = ctrl.Result{RequeueAfter: 1 * time.Second}
+ }
+ err = nil
+ return result, err
+ }
+ err = fmt.Errorf("failed to get L2VPN for status update: %w", err)
+ return result, err
+ }
+
+ // L2VPN exists - report successful assignment if not already reported
+ if apismeta.FindStatusCondition(claim.Status.Conditions, netboxv1.ConditionL2VPNAssignedTrue.Type) == nil || apismeta.IsStatusConditionFalse(claim.Status.Conditions, netboxv1.ConditionL2VPNAssignedTrue.Type) {
+ r.EventStatusRecorder.Report(ctx, claim, netboxv1.ConditionL2VPNAssignedTrue, corev1.EventTypeNormal,
+ nil, fmt.Sprintf(" assigned identifier: %d", l2vpn.Spec.Identifier))
+ }
+ // Update status based on L2VPN readiness
+ if apismeta.IsStatusConditionTrue(l2vpn.Status.Conditions, netboxv1.ConditionL2VPNReadyTrue.Type) {
+ logger.V(4).Info("l2vpn status ready true")
+ claim.Status.Identifier = l2vpn.Spec.Identifier
+ claim.Status.L2VPNName = l2vpn.Name
+ r.EventStatusRecorder.Report(ctx, claim, netboxv1.ConditionL2VPNClaimReadyTrue, corev1.EventTypeNormal, nil)
+ } else {
+ logger.V(4).Info("l2vpn status ready false")
+ r.EventStatusRecorder.Report(ctx, claim, netboxv1.ConditionL2VPNClaimReadyFalse, corev1.EventTypeWarning, reconcileErr)
+ }
+
+ return result, err
+}
+
+// tryLockOnRange serializes concurrent range-based claims against the same
+// identifier range. Returns a nil locker and nil error when the claim uses an
+// explicit identifier instead of a range, since there's no shared pool to lock.
+func (r *L2VPNClaimReconciler) tryLockOnRange(ctx context.Context, o *netboxv1.L2VPNClaim) (ll *leaselocker.LeaseLocker, cleanup context.CancelFunc, res ctrl.Result, err error) {
+ logger := log.FromContext(ctx)
+
+ if o.Spec.IdentifierRangeStart == 0 && o.Spec.IdentifierRangeEnd == 0 {
+ return nil, nil, ctrl.Result{}, nil
+ }
+
+ rangeDesc := fmt.Sprintf("%s:%d-%d", o.Spec.Type, o.Spec.IdentifierRangeStart, o.Spec.IdentifierRangeEnd)
+ leaseLockerNSN := types.NamespacedName{
+ Name: convertL2VPNRangeToLeaseLockName(o.Spec.Type, o.Spec.IdentifierRangeStart, o.Spec.IdentifierRangeEnd),
+ Namespace: r.OperatorNamespace,
+ }
+
+ claimNSN := types.NamespacedName{
+ Name: o.Name,
+ Namespace: o.Namespace,
+ }
+
+ ll, err = leaselocker.NewLeaseLocker(r.RestConfig, leaseLockerNSN, claimNSN.String())
+ if err != nil {
+ return nil, nil, ctrl.Result{}, err
+ }
+
+ lockCtx, cancel := context.WithTimeout(ctx, lockAcquireTimeout)
+
+ locked := ll.TryLock(lockCtx)
+ if !locked {
+ cancel()
+ logger.Info(fmt.Sprintf("failed to lock identifier range %s", rangeDesc))
+ r.EventStatusRecorder.Recorder().Eventf(o, corev1.EventTypeWarning, "FailedToLockIdentifierRange", "failed to lock identifier range %s",
+ rangeDesc)
+ return nil, nil, ctrl.Result{RequeueAfter: 2 * time.Second}, NewDomainError("failed to lock identifier range %s", rangeDesc)
+ }
+ logger.V(4).Info(fmt.Sprintf("successfully locked identifier range %s", rangeDesc))
+
+ cleanup = func() {
+ cancel()
+ ll.UnlockWithRetry(ctx)
+ }
+ return ll, cleanup, ctrl.Result{}, nil
+}
+
+func (r *L2VPNClaimReconciler) restoreOrAssignL2VPNAndSetCondition(ctx context.Context, o *netboxv1.L2VPNClaim) (*int64, context.CancelFunc, ctrl.Result, error) {
+ logger := log.FromContext(ctx)
+
+ _, cancelLock, res, err := r.tryLockOnRange(ctx, o)
+ if err != nil {
+ return nil, nil, res, err
+ }
+
+ h := generateL2VPNRestorationHash(o)
+ l2vpnModel, err := r.NetboxClient.RestoreExistingL2VPNByHash(ctx, h)
+ if err != nil {
+ return nil, cancelLock, ctrl.Result{}, NewDomainError("%w", err)
+ }
+
+ if l2vpnModel != nil {
+ logger.V(4).Info(fmt.Sprintf("reassign reserved l2vpn identifier from netbox: %d", l2vpnModel.Identifier))
+ return &l2vpnModel.Identifier, cancelLock, ctrl.Result{}, nil
+ }
+
+ // l2vpn cannot be restored from netbox
+ if o.Spec.Identifier != 0 {
+ // explicit identifier, nothing to look up
+ identifier := o.Spec.Identifier
+ return &identifier, cancelLock, ctrl.Result{}, nil
+ }
+
+ // range-based: assign new available identifier
+ l2vpnModel, err = r.NetboxClient.GetAvailableL2VPNIdentifierByClaim(
+ ctx,
+ &models.L2VPNClaim{
+ Type: o.Spec.Type,
+ IdentifierRangeStart: o.Spec.IdentifierRangeStart,
+ IdentifierRangeEnd: o.Spec.IdentifierRangeEnd,
+ Metadata: &models.NetboxMetadata{
+ Tenant: o.Spec.Tenant,
+ },
+ },
+ )
+ if err != nil {
+ return nil, cancelLock, ctrl.Result{}, NewDomainError("%w", err)
+ }
+ logger.V(4).Info(fmt.Sprintf("l2vpn is not reserved in netbox, assigned new identifier: %d", l2vpnModel.Identifier))
+ return &l2vpnModel.Identifier, cancelLock, ctrl.Result{}, nil
+}
diff --git a/internal/controller/l2vpnclaim_controller_test.go b/internal/controller/l2vpnclaim_controller_test.go
new file mode 100644
index 00000000..fcf92cdb
--- /dev/null
+++ b/internal/controller/l2vpnclaim_controller_test.go
@@ -0,0 +1,279 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/netbox-community/netbox-operator/gen/mock_interfaces"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/swisscom/leaselocker"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apismeta "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/types"
+
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+)
+
+var _ = Describe("L2VPNClaim Controller", Ordered, func() {
+
+ const timeout = time.Second * 4
+ const interval = time.Millisecond * 250
+
+ var unexpectedCallCh chan error
+
+ BeforeEach(func() {
+ // Initialize the channel to catch mock calls with unexpected parameters
+ unexpectedCallCh = make(chan error)
+ managerWG := sync.WaitGroup{}
+ managerWG.Add(1)
+ })
+
+ AfterEach(func() {
+ By("Resetting the mock controller")
+ resetVpnMockFunctions()
+ })
+
+ DescribeTable("Reconciler (l2vpn claim CR)", func(
+ cr *netboxv1.L2VPNClaim, // our CR as typed object
+ expectedL2VPNSpec netboxv1.L2VPNSpec, // spec expected on the L2VPN CR created by the claim controller
+ VpnAPIClaimMocks []func(*mock_interfaces.MockVpnAPI, chan error),
+ VpnClaimListRequestMocks []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error),
+ VpnAPIMocks []func(*mock_interfaces.MockVpnAPI, chan error),
+ VpnListRequestMocks []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error),
+ VpnCreateRequestMocks []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error),
+ VpnUpdateRequestMocks []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error),
+ VpnDestroyRequestMocks []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error),
+ expectedConditionReady bool, // Expected state of the ConditionReady condition
+ expectedConditionAssigned bool, // Expected state of the ConditionL2VPNAssigned condition
+ expectedCRStatus netboxv1.L2VPNClaimStatus, // Expected status of the CR
+ rangeLockedByOtherOwner bool, // If the identifier range is locked by another owner when the claim CR is created
+ ) {
+ By("Setting up mocks")
+ for _, mock := range VpnAPIClaimMocks {
+ mock(mockVpnAPIClaim, unexpectedCallCh)
+ }
+ for _, mock := range VpnClaimListRequestMocks {
+ mock(mockVpnClaimListRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnAPIMocks {
+ mock(mockVpnAPI, unexpectedCallCh)
+ }
+ for _, mock := range VpnListRequestMocks {
+ mock(mockVpnListRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnCreateRequestMocks {
+ mock(mockVpnCreateRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnUpdateRequestMocks {
+ mock(mockVpnUpdateRequest, unexpectedCallCh)
+ }
+ for _, mock := range VpnDestroyRequestMocks {
+ mock(mockVpnDestroyRequest, unexpectedCallCh)
+ }
+
+ catchCtx, catchCtxCancel := context.WithCancel(context.Background())
+ defer catchCtxCancel()
+
+ // Goroutine to monitor mock calls with unexpected parameters
+ go func() {
+ defer GinkgoRecover()
+ select {
+ case errMsg := <-unexpectedCallCh:
+ Fail(errMsg.Error())
+
+ case <-catchCtx.Done():
+ // Context was cancelled
+ }
+ }()
+
+ if rangeLockedByOtherOwner {
+ leaseLockerNSN := types.NamespacedName{
+ Name: convertL2VPNRangeToLeaseLockName(cr.Spec.Type, cr.Spec.IdentifierRangeStart, cr.Spec.IdentifierRangeEnd),
+ Namespace: OperatorNamespace,
+ }
+ ll, err := leaselocker.NewLeaseLocker(cfg, leaseLockerNSN, "default/some-other-owner")
+ Expect(err).To(BeNil())
+
+ lockCtx, lockCancel := context.WithCancel(ctx)
+ defer lockCancel()
+
+ locked := ll.TryLock(lockCtx)
+ Expect(locked).To(BeTrue())
+ }
+
+ // Create our CR
+ By("Creating L2VPNClaim CR")
+ Eventually(k8sClient.Create(ctx, cr), timeout, interval).Should(Succeed())
+
+ createdCR := &netboxv1.L2VPNClaim{}
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return err == nil
+ }, timeout, interval).Should(BeTrue())
+
+ // status L2VPNAssigned should match expectedConditionAssigned once the claim controller has run
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return err == nil &&
+ apismeta.IsStatusConditionTrue(createdCR.Status.Conditions, netboxv1.ConditionL2VPNAssignedTrue.Type) == expectedConditionAssigned
+ }, timeout, interval).Should(BeTrue())
+
+ createdL2VPNCR := &netboxv1.L2VPN{}
+ if expectedConditionAssigned {
+ // check that the l2vpn CR was created
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdL2VPNCR)
+ return err == nil
+ }, timeout, interval).Should(BeTrue())
+
+ // check that the l2vpn claim controller created the l2vpn CR with the correct spec
+ Expect(createdL2VPNCR.Spec).To(Equal(expectedL2VPNSpec))
+ }
+
+ // Now check if conditions are set as expected
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdCR)
+ return err == nil &&
+ apismeta.IsStatusConditionTrue(createdCR.Status.Conditions, netboxv1.ConditionL2VPNClaimReadyTrue.Type) == expectedConditionReady
+ }, timeout, interval).Should(BeTrue())
+
+ // Check that the expected identifier/l2vpn name are present in the status
+ Expect(createdCR.Status.Identifier).To(Equal(expectedCRStatus.Identifier))
+ Expect(createdCR.Status.L2VPNName).To(Equal(expectedCRStatus.L2VPNName))
+
+ // Cleanup the netbox resources
+ Expect(k8sClient.Delete(ctx, cr)).Should(Succeed())
+
+ // Wait until the resources are deleted to make sure that it will not interfere with the next test case
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, cr)
+ return apierrors.IsNotFound(err)
+ }, timeout, interval).Should(BeTrue())
+
+ if expectedConditionAssigned {
+ Eventually(func() bool {
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.GetName(), Namespace: cr.GetNamespace()}, createdL2VPNCR)
+ return apierrors.IsNotFound(err)
+ }, timeout, interval).Should(BeTrue())
+ }
+ },
+ Entry("Create L2VPNClaim CR, assign new identifier from range",
+ defaultL2VPNClaimCRWithRange(), expectedL2VPNSpecFromClaim(defaultL2VPNClaimCRWithRange(), l2vpnRangeStart),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIClaimList,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnClaimListRequestPaging,
+ mockVpnClaimListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPICreate,
+ mockVpnAPIDestroy,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByClaimName,
+ mockVpnListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){
+ mockVpnCreateRequestBuild,
+ mockVpnCreateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteSuccess,
+ },
+ true, true,
+ netboxv1.L2VPNClaimStatus{Identifier: l2vpnRangeStart, L2VPNName: l2vpnClaimName},
+ false),
+ Entry("Create L2VPNClaim CR, restore existing identifier from NetBox",
+ defaultL2VPNClaimCRWithRange(), expectedL2VPNSpecFromClaim(defaultL2VPNClaimCRWithRange(), l2vpnRestoredIdentifier),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIClaimList,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnClaimListRequestPaging,
+ mockVpnClaimListRequestExecuteWithMatchingHash,
+ },
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPIUpdate,
+ mockVpnAPIDestroy,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByClaimName,
+ mockVpnListRequestExecuteClaimExistingWithHash(generateL2VPNRestorationHash(defaultL2VPNClaimCRWithRange()), l2vpnRestoredIdentifier),
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){
+ mockVpnUpdateRequestBuild,
+ mockVpnUpdateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteSuccess,
+ },
+ true, true,
+ netboxv1.L2VPNClaimStatus{Identifier: l2vpnRestoredIdentifier, L2VPNName: l2vpnClaimName},
+ false),
+ Entry("Create L2VPNClaim CR, explicit identifier (no range lock)",
+ defaultL2VPNClaimCRWithIdentifier(), expectedL2VPNSpecFromClaim(defaultL2VPNClaimCRWithIdentifier(), l2vpnIdentifier),
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIClaimList,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnClaimListRequestPaging,
+ mockVpnClaimListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnAPI, chan error){
+ mockVpnAPIList,
+ mockVpnAPICreate,
+ mockVpnAPIDestroy,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){
+ mockVpnListRequestByClaimName,
+ mockVpnListRequestExecuteEmpty,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){
+ mockVpnCreateRequestBuild,
+ mockVpnCreateRequestExecuteSuccess,
+ },
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){
+ mockVpnDestroyRequestExecuteSuccess,
+ },
+ true, true,
+ netboxv1.L2VPNClaimStatus{Identifier: l2vpnIdentifier, L2VPNName: l2vpnClaimName},
+ false),
+ Entry("Create L2VPNClaim CR, identifier range locked by other resource",
+ defaultL2VPNClaimCRWithRange(), netboxv1.L2VPNSpec{},
+ []func(*mock_interfaces.MockVpnAPI, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){},
+ []func(*mock_interfaces.MockVpnAPI, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsListRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsCreateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsUpdateRequest, chan error){},
+ []func(*mock_interfaces.MockVpnL2vpnsDestroyRequest, chan error){},
+ false, false,
+ netboxv1.L2VPNClaimStatus{},
+ true),
+ )
+})
diff --git a/internal/controller/l2vpnclaim_helpers.go b/internal/controller/l2vpnclaim_helpers.go
new file mode 100644
index 00000000..b547b9de
--- /dev/null
+++ b/internal/controller/l2vpnclaim_helpers.go
@@ -0,0 +1,99 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "crypto/sha1"
+ "fmt"
+
+ "github.com/go-logr/logr"
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+func generateL2VPNFromL2VPNClaim(ctx context.Context, claim *netboxv1.L2VPNClaim, identifier int64) *netboxv1.L2VPN {
+ logger := log.FromContext(ctx)
+ l2vpnResource := &netboxv1.L2VPN{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: claim.Name,
+ Namespace: claim.Namespace,
+ },
+ Spec: generateL2VPNSpec(claim, identifier, logger),
+ }
+ return l2vpnResource
+}
+
+func generateL2VPNSpec(claim *netboxv1.L2VPNClaim, identifier int64, logger logr.Logger) netboxv1.L2VPNSpec {
+ // log a warning if the netboxOperatorRestorationHash name is a key in the customFields map of the L2VPNClaim
+ _, ok := claim.Spec.CustomFields[config.GetOperatorConfig().NetboxRestorationHashFieldName]
+ if ok {
+ logger.Info(fmt.Sprintf("Warning: restoration hash is calculated from spec, custom field with key %s will be ignored", config.GetOperatorConfig().NetboxRestorationHashFieldName))
+ }
+
+ // Copy customFields from claim and add restoration hash
+ customFields := make(map[string]string, len(claim.Spec.CustomFields)+1)
+ for k, v := range claim.Spec.CustomFields {
+ customFields[k] = v
+ }
+
+ customFields[config.GetOperatorConfig().NetboxRestorationHashFieldName] = generateL2VPNRestorationHash(claim)
+
+ return netboxv1.L2VPNSpec{
+ Name: claim.Name,
+ Type: claim.Spec.Type,
+ Identifier: identifier,
+ Tenant: claim.Spec.Tenant,
+ CustomFields: customFields,
+ Description: claim.Spec.Description,
+ Comments: claim.Spec.Comments,
+ PreserveInNetbox: claim.Spec.PreserveInNetbox,
+ }
+}
+
+func generateL2VPNRestorationHash(claim *netboxv1.L2VPNClaim) string {
+ rd := L2VPNClaimRestorationData{
+ Namespace: claim.Namespace,
+ Name: claim.Name,
+ Type: claim.Spec.Type,
+ Tenant: claim.Spec.Tenant,
+ Identifier: fmt.Sprintf("%d", claim.Spec.Identifier),
+ IdentifierRangeStart: fmt.Sprintf("%d", claim.Spec.IdentifierRangeStart),
+ IdentifierRangeEnd: fmt.Sprintf("%d", claim.Spec.IdentifierRangeEnd),
+ }
+ return fmt.Sprintf("%x", sha1.Sum([]byte(rd.Namespace+rd.Name+rd.Type+rd.Tenant+rd.Identifier+rd.IdentifierRangeStart+rd.IdentifierRangeEnd)))
+}
+
+type L2VPNClaimRestorationData struct {
+ // only use immutable fields
+ Namespace string
+ Name string
+ Type string
+ Tenant string
+ Identifier string
+ IdentifierRangeStart string
+ IdentifierRangeEnd string
+}
+
+// convertL2VPNRangeToLeaseLockName builds a lease lock name identifying the
+// shared identifier range a range-based L2VPNClaim draws from, so that
+// concurrent claims against the same range serialize their allocation.
+func convertL2VPNRangeToLeaseLockName(type_ string, start int64, end int64) string {
+ return fmt.Sprintf("l2vpn-%s-%d-%d", type_, start, end)
+}
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index 3567832e..ab82d75f 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -60,6 +60,18 @@ var ipamMockIpAddress *mock_interfaces.MockIpamInterface
var ipamMockIpAddressClaim *mock_interfaces.MockIpamInterface
var tenancyMock *mock_interfaces.MockTenancyInterface
var dcimMock *mock_interfaces.MockDcimInterface
+
+// Separate MockVpnAPI instances per reconciler, mirroring the ipamMockIpAddress/
+// ipamMockIpAddressClaim split above: both L2VPNReconciler and L2VPNClaimReconciler
+// call VpnAPI.VpnL2vpnsList independently, so sharing one mock would make it
+// ambiguous which reconciler's call a given expectation belongs to.
+var mockVpnAPI *mock_interfaces.MockVpnAPI
+var mockVpnListRequest *mock_interfaces.MockVpnL2vpnsListRequest
+var mockVpnCreateRequest *mock_interfaces.MockVpnL2vpnsCreateRequest
+var mockVpnUpdateRequest *mock_interfaces.MockVpnL2vpnsUpdateRequest
+var mockVpnDestroyRequest *mock_interfaces.MockVpnL2vpnsDestroyRequest
+var mockVpnAPIClaim *mock_interfaces.MockVpnAPI
+var mockVpnClaimListRequest *mock_interfaces.MockVpnL2vpnsListRequest
var ctx context.Context
var cancel context.CancelFunc
@@ -117,6 +129,14 @@ var _ = BeforeSuite(func() {
mockIpamAPI = mock_interfaces.NewMockIpamAPI(mockCtrl)
mockIpamPrefixesListRequest = mock_interfaces.NewMockIpamPrefixesListRequest(mockCtrl)
+ mockVpnAPI = mock_interfaces.NewMockVpnAPI(mockCtrl)
+ mockVpnListRequest = mock_interfaces.NewMockVpnL2vpnsListRequest(mockCtrl)
+ mockVpnCreateRequest = mock_interfaces.NewMockVpnL2vpnsCreateRequest(mockCtrl)
+ mockVpnUpdateRequest = mock_interfaces.NewMockVpnL2vpnsUpdateRequest(mockCtrl)
+ mockVpnDestroyRequest = mock_interfaces.NewMockVpnL2vpnsDestroyRequest(mockCtrl)
+ mockVpnAPIClaim = mock_interfaces.NewMockVpnAPI(mockCtrl)
+ mockVpnClaimListRequest = mock_interfaces.NewMockVpnL2vpnsListRequest(mockCtrl)
+
k8sManager, err := ctrl.NewManager(cfg, k8sManagerOptions)
Expect(k8sManager.GetConfig()).NotTo(BeNil())
Expect(err).ToNot(HaveOccurred())
@@ -155,6 +175,32 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())
+ err = (&L2VPNReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ EventStatusRecorder: NewEventStatusRecorder(k8sManager.GetEventRecorderFor("l2vpn-controller")), //nolint:staticcheck // using deprecated API until controller-runtime migration is complete
+ NetboxClient: api.NewNetboxCompositeClient(
+ &api.NetboxClientV3{},
+ &api.NetboxClientV4{VpnAPI: mockVpnAPI},
+ ),
+ OperatorNamespace: OperatorNamespace,
+ RestConfig: k8sManager.GetConfig(),
+ }).SetupWithManager(k8sManager)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = (&L2VPNClaimReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ EventStatusRecorder: NewEventStatusRecorder(k8sManager.GetEventRecorderFor("l2vpn-claim-controller")), //nolint:staticcheck // using deprecated API until controller-runtime migration is complete
+ NetboxClient: api.NewNetboxCompositeClient(
+ &api.NetboxClientV3{},
+ &api.NetboxClientV4{VpnAPI: mockVpnAPIClaim},
+ ),
+ OperatorNamespace: OperatorNamespace,
+ RestConfig: k8sManager.GetConfig(),
+ }).SetupWithManager(k8sManager)
+ Expect(err).ToNot(HaveOccurred())
+
ctx, cancel = context.WithCancel(context.TODO())
go func() {
defer GinkgoRecover()
diff --git a/kind/load-local-data-job/main.py b/kind/load-local-data-job/main.py
index a91e874c..98928ac6 100644
--- a/kind/load-local-data-job/main.py
+++ b/kind/load-local-data-job/main.py
@@ -113,8 +113,8 @@ class CustomField:
custom_fields = [
CustomField(
- content_types=["ipam.ipaddress", "ipam.iprange", "ipam.prefix"],
- object_types=["ipam.ipaddress", "ipam.iprange", "ipam.prefix"],
+ content_types=["ipam.ipaddress", "ipam.iprange", "ipam.prefix", "vpn.l2vpn"],
+ object_types=["ipam.ipaddress", "ipam.iprange", "ipam.prefix", "vpn.l2vpn"],
type="text",
name="netboxOperatorRestorationHash",
label="Netbox Restoration Hash",
diff --git a/pkg/netbox/api/clientv4.go b/pkg/netbox/api/clientv4.go
index 5b259098..b12b15a9 100644
--- a/pkg/netbox/api/clientv4.go
+++ b/pkg/netbox/api/clientv4.go
@@ -33,6 +33,7 @@ import (
type NetboxClientV4 struct {
client *v4client.APIClient
IpamAPI interfaces.IpamAPI
+ VpnAPI interfaces.VpnAPI
StatusAPI interfaces.StatusAPI
}
@@ -80,6 +81,7 @@ func GetNetboxClientV4() (*NetboxClientV4, error) {
return &NetboxClientV4{
client: client,
IpamAPI: &ipamV4APIAdapter{api: client.IpamAPI},
+ VpnAPI: &vpnV4APIAdapter{api: client.VpnAPI},
StatusAPI: &statusV4APIAdapter{api: client.StatusAPI},
}, nil
}
diff --git a/pkg/netbox/api/errors.go b/pkg/netbox/api/errors.go
index 2492bc95..17755459 100644
--- a/pkg/netbox/api/errors.go
+++ b/pkg/netbox/api/errors.go
@@ -24,4 +24,5 @@ var (
ErrWrongMatchingPrefixSubnetFormat = errors.New("wrong matchingPrefix subnet format")
ErrInvalidIpFamily = errors.New("invalid IP Family")
ErrRestorationHashMismatch = errors.New("restoration hash mismatch")
+ ErrL2VPNRangeExhausted = errors.New("l2vpn identifier range exhausted")
)
diff --git a/pkg/netbox/api/l2vpn.go b/pkg/netbox/api/l2vpn.go
new file mode 100644
index 00000000..df25a160
--- /dev/null
+++ b/pkg/netbox/api/l2vpn.go
@@ -0,0 +1,207 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ v4client "github.com/netbox-community/go-netbox/v4"
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/utils"
+)
+
+var slugInvalidCharsRegex = regexp.MustCompile(`[^-a-zA-Z0-9_]+`)
+
+// slugify converts name into a value that satisfies NetBox's slug field
+// constraints (matches ^[-a-zA-Z0-9_]+$, max 100 characters).
+func slugify(name string) string {
+ slug := slugInvalidCharsRegex.ReplaceAllString(strings.TrimSpace(name), "-")
+ slug = strings.Trim(slug, "-")
+ if len(slug) > 100 {
+ slug = strings.Trim(slug[:100], "-")
+ }
+ if slug == "" {
+ slug = "l2vpn"
+ }
+ return slug
+}
+
+func (c *NetboxCompositeClient) ReserveOrUpdateL2VPN(ctx context.Context, l2vpn *models.L2VPN, l2vpnV1 *netboxv1.L2VPN) (resp *v4client.L2VPN, isUpToDate bool, err error) {
+ responseL2VPNList, err := c.getL2VPN(ctx, l2vpn)
+ if err != nil {
+ return nil, false, err
+ }
+
+ desiredL2VPN := v4client.NewWritableL2VPNRequest(l2vpn.Name, slugify(l2vpn.Name), v4client.BriefL2VPNTypeValue(l2vpn.Type))
+ desiredL2VPN.SetIdentifier(l2vpn.Identifier)
+ desiredL2VPN.SetStatus(v4client.L2VPNSTATUSVALUE_ACTIVE)
+
+ if l2vpn.Metadata != nil {
+ desiredL2VPN.SetComments(l2vpn.Metadata.Comments + warningComment)
+ // Convert map[string]string to map[string]interface{}
+ customFields := make(map[string]interface{}, len(l2vpn.Metadata.Custom))
+ for k, v := range l2vpn.Metadata.Custom {
+ customFields[k] = v
+ }
+ desiredL2VPN.SetCustomFields(customFields)
+ desiredL2VPN.SetDescription(TruncateDescription(l2vpn.Metadata.Description))
+ if l2vpn.Metadata.Tenant != "" {
+ tenantDetails, err := c.getTenantDetails(l2vpn.Metadata.Tenant)
+ if err != nil {
+ return nil, false, err
+ }
+ tenantId := int32(tenantDetails.Id)
+ desiredL2VPN.SetTenant(v4client.Int32AsASNRangeRequestTenant(&tenantId))
+ }
+ }
+
+ // create l2vpn since it doesn't exist
+ if len(responseL2VPNList.Results) == 0 {
+ resp, err := c.createL2VPN(ctx, desiredL2VPN)
+ return resp, false, err
+ }
+
+ l2vpnToUpdate := &responseL2VPNList.Results[0]
+
+ if !l2vpnToUpdate.LastUpdated.IsSet() {
+ return nil, false, fmt.Errorf("last updated field is not set in Netbox for l2vpn %s", l2vpn.Name)
+ }
+
+ // if the desired l2vpn has a restoration hash
+ // check that the l2vpn to update has the same restoration hash
+ restorationHashKey := config.GetOperatorConfig().NetboxRestorationHashFieldName
+ if l2vpn.Metadata != nil {
+ if restorationHash, ok := l2vpn.Metadata.Custom[restorationHashKey]; ok {
+ if l2vpnToUpdate.CustomFields != nil && l2vpnToUpdate.CustomFields[restorationHashKey] == restorationHash {
+ if IsUpToDate(ctx, *l2vpnToUpdate.LastUpdated.Get(), l2vpnV1.Status.LastUpdated, l2vpnV1.Status.Conditions, l2vpnV1.Generation) {
+ return nil, true, nil
+ }
+
+ //update l2vpn since it does exist and the restoration hash matches
+ resp, err := c.updateL2VPN(ctx, l2vpnToUpdate.Id, desiredL2VPN)
+ if err != nil {
+ return nil, false, err
+ }
+ return resp, false, nil
+ }
+ return nil, false, fmt.Errorf("%w, assigned l2vpn identifier %d", ErrRestorationHashMismatch, l2vpn.Identifier)
+ }
+ }
+
+ if IsUpToDate(ctx, *l2vpnToUpdate.LastUpdated.Get(), l2vpnV1.Status.LastUpdated, l2vpnV1.Status.Conditions, l2vpnV1.Generation) {
+ return nil, true, nil
+ }
+
+ //update l2vpn since it does exist
+ l2vpnId := responseL2VPNList.Results[0].Id
+ resp, err = c.updateL2VPN(ctx, l2vpnId, desiredL2VPN)
+ if err != nil {
+ return nil, false, err
+ }
+ return resp, false, nil
+}
+
+func (c *NetboxCompositeClient) getL2VPN(ctx context.Context, l2vpn *models.L2VPN) (*v4client.PaginatedL2VPNList, error) {
+ req := c.clientV4.VpnAPI.VpnL2vpnsList(ctx).
+ Name([]string{l2vpn.Name})
+ resp, httpResp, err := req.Execute()
+
+ var body []byte
+ var readErr error
+ if httpResp != nil && httpResp.Body != nil {
+ defer func() {
+ errClose := httpResp.Body.Close()
+ err = errors.Join(err, errClose)
+ }()
+ body, readErr = io.ReadAll(httpResp.Body)
+ }
+
+ if httpResp == nil {
+ return nil, fmt.Errorf("failed to fetch l2vpn details: %w", err)
+ }
+
+ if httpResp.StatusCode != http.StatusOK {
+ if readErr != nil {
+ return nil, fmt.Errorf("failed to fetch l2vpn details: status %d; read body: %w", httpResp.StatusCode, readErr)
+ }
+ return nil, fmt.Errorf("failed to fetch l2vpn details: status %d, body: %s", httpResp.StatusCode, string(body))
+ }
+
+ if err != nil {
+ return nil, utils.NetboxError("failed to fetch l2vpn details", err)
+ }
+
+ return resp, nil
+}
+
+func (c *NetboxCompositeClient) createL2VPN(ctx context.Context, l2vpn *v4client.WritableL2VPNRequest) (resp *v4client.L2VPN, err error) {
+ req := c.clientV4.VpnAPI.VpnL2vpnsCreate(ctx).WritableL2VPNRequest(*l2vpn)
+ resp, httpResp, execErr := req.Execute()
+
+ closeFunc, handleErr := handleHTTPResponse(httpResp, execErr, http.StatusCreated, "reserve l2vpn")
+ if closeFunc != nil {
+ defer func() { err = errors.Join(err, closeFunc()) }()
+ }
+ if handleErr != nil {
+ return nil, handleErr
+ }
+
+ return resp, nil
+}
+
+func (c *NetboxCompositeClient) updateL2VPN(ctx context.Context, l2vpnId int32, l2vpn *v4client.WritableL2VPNRequest) (resp *v4client.L2VPN, err error) {
+ req := c.clientV4.VpnAPI.VpnL2vpnsUpdate(ctx, l2vpnId).WritableL2VPNRequest(*l2vpn)
+ resp, httpResp, execErr := req.Execute()
+
+ closeFunc, handleErr := handleHTTPResponse(httpResp, execErr, http.StatusOK, "update l2vpn")
+ if closeFunc != nil {
+ defer func() { err = errors.Join(err, closeFunc()) }()
+ }
+ if handleErr != nil {
+ return nil, handleErr
+ }
+
+ return resp, nil
+}
+
+func (c *NetboxCompositeClient) DeleteL2VPN(ctx context.Context, l2vpnId int32) (err error) {
+ req := c.clientV4.VpnAPI.VpnL2vpnsDestroy(ctx, l2vpnId)
+ httpResp, execErr := req.Execute()
+
+ if httpResp != nil && httpResp.StatusCode == http.StatusNotFound {
+ return nil
+ }
+
+ closeFunc, handleErr := handleHTTPResponse(httpResp, execErr, http.StatusNoContent, "delete l2vpn from netbox")
+ if closeFunc != nil {
+ defer func() { err = errors.Join(err, closeFunc()) }()
+ }
+ if handleErr != nil {
+ return handleErr
+ }
+
+ return nil
+}
diff --git a/pkg/netbox/api/l2vpn_claim.go b/pkg/netbox/api/l2vpn_claim.go
new file mode 100644
index 00000000..b9609502
--- /dev/null
+++ b/pkg/netbox/api/l2vpn_claim.go
@@ -0,0 +1,155 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "slices"
+
+ v4client "github.com/netbox-community/go-netbox/v4"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+)
+
+// l2vpnListPageSize is the page size used when paginating through NetBox's
+// L2VPN list endpoint. NetBox has no "available identifiers" endpoint for
+// L2VPN the way it does for prefixes/IP ranges, so restoration-by-hash lookup
+// and free-identifier search both have to list and scan client-side.
+const l2vpnListPageSize = int32(100)
+
+// forEachL2VPN pages through NetBox's entire L2VPN list and invokes visit for
+// every result. visit returns false to stop iterating early.
+func (c *NetboxCompositeClient) forEachL2VPN(ctx context.Context, visit func(l2vpn *v4client.L2VPN) bool) error {
+ var offset int32
+ for {
+ req := c.clientV4.VpnAPI.VpnL2vpnsList(ctx).Limit(l2vpnListPageSize).Offset(offset)
+ resp, httpResp, err := req.Execute()
+
+ if httpResp != nil && httpResp.Body != nil {
+ _, _ = io.ReadAll(httpResp.Body)
+ closeErr := httpResp.Body.Close()
+ err = errors.Join(err, closeErr)
+ }
+ if httpResp == nil {
+ return fmt.Errorf("failed to list l2vpns: %w", err)
+ }
+ if httpResp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to list l2vpns: status %d", httpResp.StatusCode)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to list l2vpns: %w", err)
+ }
+
+ for i := range resp.Results {
+ if !visit(&resp.Results[i]) {
+ return nil
+ }
+ }
+
+ offset += int32(len(resp.Results))
+ if offset >= resp.Count || len(resp.Results) == 0 {
+ return nil
+ }
+ }
+}
+
+// RestoreExistingL2VPNByHash searches for an existing NetBox L2VPN whose
+// restoration hash custom field matches hash. Returns nil if none is found.
+func (c *NetboxCompositeClient) RestoreExistingL2VPNByHash(ctx context.Context, hash string) (*models.L2VPN, error) {
+ hashKey := config.GetOperatorConfig().NetboxRestorationHashFieldName
+
+ var found *v4client.L2VPN
+ err := c.forEachL2VPN(ctx, func(l2vpn *v4client.L2VPN) bool {
+ if l2vpn.CustomFields == nil {
+ return true
+ }
+ if v, ok := l2vpn.CustomFields[hashKey]; ok && v == hash {
+ found = l2vpn
+ return false
+ }
+ return true
+ })
+ if err != nil {
+ return nil, err
+ }
+ if found == nil {
+ return nil, nil
+ }
+
+ identifier := int64(0)
+ if found.Identifier.IsSet() && found.Identifier.Get() != nil {
+ identifier = *found.Identifier.Get()
+ }
+
+ return &models.L2VPN{
+ Name: found.Name,
+ Slug: found.Slug,
+ Identifier: identifier,
+ Id: int64(found.Id),
+ }, nil
+}
+
+// GetAvailableL2VPNIdentifierByClaim searches for a free VNI in the range
+// requested by claim, by listing L2VPNs of the claim's type already using an
+// identifier within [claim.IdentifierRangeStart, claim.IdentifierRangeEnd]
+// and returning the first gap.
+func (c *NetboxCompositeClient) GetAvailableL2VPNIdentifierByClaim(ctx context.Context, claim *models.L2VPNClaim) (*models.L2VPN, error) {
+ if claim.Metadata != nil && claim.Metadata.Tenant != "" {
+ if _, err := c.getTenantDetails(claim.Metadata.Tenant); err != nil {
+ return nil, err
+ }
+ }
+
+ var used []int64
+ err := c.forEachL2VPN(ctx, func(l2vpn *v4client.L2VPN) bool {
+ if !l2vpn.Identifier.IsSet() || l2vpn.Identifier.Get() == nil {
+ return true
+ }
+ id := *l2vpn.Identifier.Get()
+ if id >= claim.IdentifierRangeStart && id <= claim.IdentifierRangeEnd {
+ used = append(used, id)
+ }
+ return true
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ slices.Sort(used)
+
+ next := claim.IdentifierRangeStart
+ for _, id := range used {
+ if id > next {
+ break
+ }
+ if id == next {
+ next++
+ }
+ }
+
+ if next > claim.IdentifierRangeEnd {
+ return nil, ErrL2VPNRangeExhausted
+ }
+
+ return &models.L2VPN{
+ Identifier: next,
+ }, nil
+}
diff --git a/pkg/netbox/api/l2vpn_claim_test.go b/pkg/netbox/api/l2vpn_claim_test.go
new file mode 100644
index 00000000..9e311b2a
--- /dev/null
+++ b/pkg/netbox/api/l2vpn_claim_test.go
@@ -0,0 +1,256 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/netbox-community/go-netbox/v3/netbox/client/tenancy"
+ netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models"
+ v4client "github.com/netbox-community/go-netbox/v4"
+ "github.com/netbox-community/netbox-operator/gen/mock_interfaces"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+ "github.com/stretchr/testify/assert"
+ "go.uber.org/mock/gomock"
+)
+
+func l2vpnWithIdentifier(id int64) v4client.L2VPN {
+ l2vpn := v4client.L2VPN{}
+ l2vpn.Identifier = *v4client.NewNullableInt64(&id)
+ return l2vpn
+}
+
+func TestRestoreExistingL2VPNByHash(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ t.Run("found", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ identifier := int64(5000123)
+ match := l2vpnWithIdentifier(identifier)
+ match.Name = "l2vpnclaim-sample"
+ match.Slug = "l2vpnclaim-sample"
+ match.CustomFields = map[string]interface{}{"netboxOperatorRestorationHash": "matching-hash"}
+
+ other := l2vpnWithIdentifier(4001)
+ other.CustomFields = map[string]interface{}{"netboxOperatorRestorationHash": "other-hash"}
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 2,
+ Results: []v4client.L2VPN{other, match},
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.RestoreExistingL2VPNByHash(context.TODO(), "matching-hash")
+ AssertNil(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, identifier, result.Identifier)
+ assert.Equal(t, "l2vpnclaim-sample", result.Name)
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 0,
+ Results: []v4client.L2VPN{},
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.RestoreExistingL2VPNByHash(context.TODO(), "no-such-hash")
+ AssertNil(t, err)
+ assert.Nil(t, result)
+ })
+
+ t.Run("paginates across multiple pages", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequestPage1 := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+ mockListRequestPage2 := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ match := l2vpnWithIdentifier(9999)
+ match.CustomFields = map[string]interface{}{"netboxOperatorRestorationHash": "page2-hash"}
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequestPage1)
+ mockListRequestPage1.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequestPage1)
+ mockListRequestPage1.EXPECT().Offset(int32(0)).Return(mockListRequestPage1)
+ mockListRequestPage1.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 2,
+ Results: []v4client.L2VPN{l2vpnWithIdentifier(1)},
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequestPage2)
+ mockListRequestPage2.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequestPage2)
+ mockListRequestPage2.EXPECT().Offset(int32(1)).Return(mockListRequestPage2)
+ mockListRequestPage2.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 2,
+ Results: []v4client.L2VPN{match},
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.RestoreExistingL2VPNByHash(context.TODO(), "page2-hash")
+ AssertNil(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, int64(9999), result.Identifier)
+ })
+}
+
+func TestGetAvailableL2VPNIdentifierByClaim(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ t.Run("empty range returns start", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 0,
+ Results: []v4client.L2VPN{},
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.GetAvailableL2VPNIdentifierByClaim(context.TODO(), &models.L2VPNClaim{
+ Type: "vxlan-evpn",
+ IdentifierRangeStart: 4000,
+ IdentifierRangeEnd: 16777215,
+ })
+ AssertNil(t, err)
+ assert.Equal(t, int64(4000), result.Identifier)
+ })
+
+ t.Run("finds gap after contiguous block from start", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 3,
+ Results: []v4client.L2VPN{
+ l2vpnWithIdentifier(4000),
+ l2vpnWithIdentifier(4002),
+ l2vpnWithIdentifier(4001),
+ },
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.GetAvailableL2VPNIdentifierByClaim(context.TODO(), &models.L2VPNClaim{
+ Type: "vxlan-evpn",
+ IdentifierRangeStart: 4000,
+ IdentifierRangeEnd: 16777215,
+ })
+ AssertNil(t, err)
+ assert.Equal(t, int64(4003), result.Identifier)
+ })
+
+ t.Run("ignores identifiers outside the requested range", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 1,
+ Results: []v4client.L2VPN{
+ l2vpnWithIdentifier(9999999),
+ },
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.GetAvailableL2VPNIdentifierByClaim(context.TODO(), &models.L2VPNClaim{
+ Type: "vxlan-evpn",
+ IdentifierRangeStart: 4000,
+ IdentifierRangeEnd: 4001,
+ })
+ AssertNil(t, err)
+ assert.Equal(t, int64(4000), result.Identifier)
+ })
+
+ t.Run("returns exhausted error when range is full", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Limit(l2vpnListPageSize).Return(mockListRequest)
+ mockListRequest.EXPECT().Offset(int32(0)).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{
+ Count: 2,
+ Results: []v4client.L2VPN{
+ l2vpnWithIdentifier(4000),
+ l2vpnWithIdentifier(4001),
+ },
+ }, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ result, err := compositeClient.GetAvailableL2VPNIdentifierByClaim(context.TODO(), &models.L2VPNClaim{
+ Type: "vxlan-evpn",
+ IdentifierRangeStart: 4000,
+ IdentifierRangeEnd: 4001,
+ })
+ assert.ErrorIs(t, err, ErrL2VPNRangeExhausted)
+ assert.Nil(t, result)
+ })
+
+ t.Run("returns error for non-existing tenant without listing l2vpns", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl)
+
+ tenantName := "nonexistingtenant"
+ tenancyListInput := tenancy.NewTenancyTenantsListParams().WithName(&tenantName)
+ mockTenancy.EXPECT().TenancyTenantsList(tenancyListInput, nil).Return(&tenancy.TenancyTenantsListOK{
+ Payload: &tenancy.TenancyTenantsListOKBody{Results: []*netboxModels.Tenant{}},
+ }, nil)
+
+ compositeClient := &NetboxCompositeClient{
+ clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI},
+ clientV3: &NetboxClientV3{Tenancy: mockTenancy},
+ }
+
+ result, err := compositeClient.GetAvailableL2VPNIdentifierByClaim(context.TODO(), &models.L2VPNClaim{
+ Type: "vxlan-evpn",
+ IdentifierRangeStart: 4000,
+ IdentifierRangeEnd: 4001,
+ Metadata: &models.NetboxMetadata{
+ Tenant: tenantName,
+ },
+ })
+ AssertError(t, err, "failed to fetch tenant 'nonexistingtenant': not found")
+ assert.Nil(t, result)
+ })
+}
diff --git a/pkg/netbox/api/l2vpn_test.go b/pkg/netbox/api/l2vpn_test.go
new file mode 100644
index 00000000..2e69d508
--- /dev/null
+++ b/pkg/netbox/api/l2vpn_test.go
@@ -0,0 +1,254 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/netbox-community/go-netbox/v3/netbox/client/tenancy"
+ netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models"
+ v4client "github.com/netbox-community/go-netbox/v4"
+ netboxv1 "github.com/netbox-community/netbox-operator/api/v1"
+ "github.com/netbox-community/netbox-operator/gen/mock_interfaces"
+ "github.com/netbox-community/netbox-operator/pkg/config"
+ "github.com/netbox-community/netbox-operator/pkg/netbox/models"
+ "github.com/stretchr/testify/assert"
+ "go.uber.org/mock/gomock"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const L2VPNId = int32(7)
+
+func TestSlugify(t *testing.T) {
+ assert.Equal(t, "my-l2vpn-1", slugify("my l2vpn 1"))
+ assert.Equal(t, "leading-trailing", slugify("--leading-trailing--"))
+ assert.Equal(t, "l2vpn", slugify(""))
+ assert.Equal(t, "l2vpn", slugify("###"))
+}
+
+func TestL2VPN(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ name := "l2vpn-sample"
+ l2vpnType := "vxlan-evpn"
+ identifier := int64(5000123)
+ tenantId := int64(2)
+ tenantName := "Tenant1"
+ comments := Comments
+ description := Description
+
+ expectedTenant := v4client.NewBriefTenant(int32(tenantId), "", "", tenantName, "")
+ expectedLastUpdated := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+
+ expectedL2VPN := func() v4client.L2VPN {
+ lastUpdated := expectedLastUpdated
+ l2vpn := v4client.L2VPN{
+ Id: L2VPNId,
+ Name: name,
+ Slug: slugify(name),
+ Comments: &comments,
+ Description: &description,
+ Tenant: *v4client.NewNullableBriefTenant(expectedTenant),
+ LastUpdated: *v4client.NewNullableTime(&lastUpdated),
+ }
+ l2vpn.Identifier = *v4client.NewNullableInt64(&identifier)
+ return l2vpn
+ }
+
+ t.Run("reserve new l2vpn", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl)
+ mockCreateRequest := mock_interfaces.NewMockVpnL2vpnsCreateRequest(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Name([]string{name}).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{Results: []v4client.L2VPN{}}, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsCreate(gomock.Any()).Return(mockCreateRequest)
+ mockCreateRequest.EXPECT().WritableL2VPNRequest(gomock.Any()).Return(mockCreateRequest)
+
+ expectedResp := expectedL2VPN()
+ mockCreateRequest.EXPECT().Execute().Return(&expectedResp, &http.Response{StatusCode: 201, Body: http.NoBody}, nil)
+
+ tenancyListInput := tenancy.NewTenancyTenantsListParams().WithName(&tenantName)
+ tenancyListOutput := &tenancy.TenancyTenantsListOK{
+ Payload: &tenancy.TenancyTenantsListOKBody{
+ Results: []*netboxModels.Tenant{
+ {ID: tenantId, Name: &tenantName, Slug: &tenantName},
+ },
+ },
+ }
+ mockTenancy.EXPECT().TenancyTenantsList(tenancyListInput, nil).Return(tenancyListOutput, nil).AnyTimes()
+
+ compositeClient := &NetboxCompositeClient{
+ clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI},
+ clientV3: &NetboxClientV3{Tenancy: mockTenancy},
+ }
+
+ actual, isUpToDate, err := compositeClient.ReserveOrUpdateL2VPN(context.TODO(),
+ &models.L2VPN{
+ Name: name,
+ Type: l2vpnType,
+ Identifier: identifier,
+ Metadata: &models.NetboxMetadata{
+ Tenant: tenantName,
+ },
+ }, &netboxv1.L2VPN{})
+
+ AssertNil(t, err)
+ assert.False(t, isUpToDate)
+ assert.NotNil(t, actual)
+ assert.Equal(t, L2VPNId, actual.Id)
+ assert.Equal(t, name, actual.Name)
+ })
+
+ t.Run("restoration hash mismatch", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Name([]string{name}).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{Results: []v4client.L2VPN{
+ {
+ CustomFields: map[string]interface{}{"netboxOperatorRestorationHash": "abc"},
+ LastUpdated: *v4client.NewNullableTime(&expectedLastUpdated),
+ },
+ }}, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ expectedHash := "ffjrep8b29fdaikb"
+ result, isUpToDate, err := compositeClient.ReserveOrUpdateL2VPN(
+ context.TODO(),
+ &models.L2VPN{
+ Name: name,
+ Type: l2vpnType,
+ Identifier: identifier,
+ Metadata: &models.NetboxMetadata{
+ Custom: map[string]string{
+ config.GetOperatorConfig().NetboxRestorationHashFieldName: expectedHash,
+ },
+ },
+ }, &netboxv1.L2VPN{})
+
+ AssertError(t, err, "restoration hash mismatch, assigned l2vpn identifier 5000123")
+ assert.False(t, isUpToDate)
+ assert.Nil(t, result)
+ })
+
+ t.Run("update existing l2vpn", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+ mockUpdateRequest := mock_interfaces.NewMockVpnL2vpnsUpdateRequest(ctrl)
+
+ existing := expectedL2VPN()
+ existing.CustomFields = map[string]interface{}{"netboxOperatorRestorationHash": "abc"}
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Name([]string{name}).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{Results: []v4client.L2VPN{existing}}, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsUpdate(gomock.Any(), L2VPNId).Return(mockUpdateRequest)
+ mockUpdateRequest.EXPECT().WritableL2VPNRequest(gomock.Any()).Return(mockUpdateRequest)
+
+ updated := expectedL2VPN()
+ mockUpdateRequest.EXPECT().Execute().Return(&updated, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ actual, isUpToDate, err := compositeClient.ReserveOrUpdateL2VPN(
+ context.TODO(),
+ &models.L2VPN{Name: name, Type: l2vpnType, Identifier: identifier},
+ &netboxv1.L2VPN{})
+
+ AssertNil(t, err)
+ assert.False(t, isUpToDate)
+ assert.NotNil(t, actual)
+ assert.Equal(t, L2VPNId, actual.Id)
+ })
+
+ t.Run("skip update when up to date", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockListRequest := mock_interfaces.NewMockVpnL2vpnsListRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsList(gomock.Any()).Return(mockListRequest)
+ mockListRequest.EXPECT().Name([]string{name}).Return(mockListRequest)
+ mockListRequest.EXPECT().Execute().Return(&v4client.PaginatedL2VPNList{Results: []v4client.L2VPN{expectedL2VPN()}}, &http.Response{StatusCode: 200, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ lastUpdatedV1 := metav1.NewTime(*expectedL2VPN().LastUpdated.Get())
+ actual, isUpToDate, err := compositeClient.ReserveOrUpdateL2VPN(
+ context.TODO(),
+ &models.L2VPN{Name: name, Type: l2vpnType, Identifier: identifier},
+ &netboxv1.L2VPN{
+ Status: netboxv1.L2VPNStatus{
+ LastUpdated: lastUpdatedV1,
+ Conditions: []metav1.Condition{
+ {Type: "Ready", Status: "True", ObservedGeneration: 0},
+ },
+ },
+ })
+ AssertNil(t, err)
+ assert.True(t, isUpToDate)
+ assert.Nil(t, actual)
+ })
+
+ t.Run("delete l2vpn", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockDestroyRequest := mock_interfaces.NewMockVpnL2vpnsDestroyRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsDestroy(gomock.Any(), L2VPNId).Return(mockDestroyRequest)
+ mockDestroyRequest.EXPECT().Execute().Return(&http.Response{StatusCode: 204, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ err := compositeClient.DeleteL2VPN(context.TODO(), L2VPNId)
+ AssertNil(t, err)
+ })
+
+ t.Run("delete l2vpn ignore 404 error", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockDestroyRequest := mock_interfaces.NewMockVpnL2vpnsDestroyRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsDestroy(gomock.Any(), L2VPNId).Return(mockDestroyRequest)
+ mockDestroyRequest.EXPECT().Execute().Return(&http.Response{StatusCode: 404, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ err := compositeClient.DeleteL2VPN(context.TODO(), L2VPNId)
+ AssertNil(t, err)
+ })
+
+ t.Run("delete l2vpn returns non 404 errors", func(t *testing.T) {
+ mockVpnAPI := mock_interfaces.NewMockVpnAPI(ctrl)
+ mockDestroyRequest := mock_interfaces.NewMockVpnL2vpnsDestroyRequest(ctrl)
+
+ mockVpnAPI.EXPECT().VpnL2vpnsDestroy(gomock.Any(), L2VPNId).Return(mockDestroyRequest)
+ mockDestroyRequest.EXPECT().Execute().Return(&http.Response{StatusCode: 400, Body: http.NoBody}, nil)
+
+ compositeClient := &NetboxCompositeClient{clientV4: &NetboxClientV4{VpnAPI: mockVpnAPI}}
+
+ err := compositeClient.DeleteL2VPN(context.TODO(), L2VPNId)
+ AssertError(t, err, "failed to delete l2vpn from netbox: status 400, body: ")
+ })
+}
diff --git a/pkg/netbox/api/v4_adapters.go b/pkg/netbox/api/v4_adapters.go
index b50ac223..ebab3c84 100644
--- a/pkg/netbox/api/v4_adapters.go
+++ b/pkg/netbox/api/v4_adapters.go
@@ -170,6 +170,103 @@ func (a *ipamV4APIAdapter) IpamPrefixesDestroy(ctx context.Context, id int32) in
return &ipamPrefixesDestroyRequestAdapter{req: a.api.IpamPrefixesDestroy(ctx, id)}
}
+// vpnL2vpnsListRequestAdapter adapts the v4 list request to the interface
+type vpnL2vpnsListRequestAdapter struct {
+ req v4client.ApiVpnL2vpnsListRequest
+}
+
+func (a *vpnL2vpnsListRequestAdapter) Name(name []string) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.Name(name)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) Type_(type_ []string) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.Type_(type_)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) IdentifierGte(identifierGte []int32) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.IdentifierGte(identifierGte)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) IdentifierLte(identifierLte []int32) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.IdentifierLte(identifierLte)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) Limit(limit int32) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.Limit(limit)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) Offset(offset int32) interfaces.VpnL2vpnsListRequest {
+ a.req = a.req.Offset(offset)
+ return a
+}
+
+func (a *vpnL2vpnsListRequestAdapter) Execute() (*v4client.PaginatedL2VPNList, *http.Response, error) {
+ return a.req.Execute()
+}
+
+// vpnL2vpnsCreateRequestAdapter adapts the v4 create request to the interface
+type vpnL2vpnsCreateRequestAdapter struct {
+ req v4client.ApiVpnL2vpnsCreateRequest
+}
+
+func (a *vpnL2vpnsCreateRequestAdapter) WritableL2VPNRequest(writableL2VPNRequest v4client.WritableL2VPNRequest) interfaces.VpnL2vpnsCreateRequest {
+ a.req = a.req.WritableL2VPNRequest(writableL2VPNRequest)
+ return a
+}
+
+func (a *vpnL2vpnsCreateRequestAdapter) Execute() (*v4client.L2VPN, *http.Response, error) {
+ return a.req.Execute()
+}
+
+// vpnL2vpnsUpdateRequestAdapter adapts the v4 update request to the interface
+type vpnL2vpnsUpdateRequestAdapter struct {
+ req v4client.ApiVpnL2vpnsUpdateRequest
+}
+
+func (a *vpnL2vpnsUpdateRequestAdapter) WritableL2VPNRequest(writableL2VPNRequest v4client.WritableL2VPNRequest) interfaces.VpnL2vpnsUpdateRequest {
+ a.req = a.req.WritableL2VPNRequest(writableL2VPNRequest)
+ return a
+}
+
+func (a *vpnL2vpnsUpdateRequestAdapter) Execute() (*v4client.L2VPN, *http.Response, error) {
+ return a.req.Execute()
+}
+
+// vpnL2vpnsDestroyRequestAdapter adapts the v4 destroy request to the interface
+type vpnL2vpnsDestroyRequestAdapter struct {
+ req v4client.ApiVpnL2vpnsDestroyRequest
+}
+
+func (a *vpnL2vpnsDestroyRequestAdapter) Execute() (*http.Response, error) {
+ return a.req.Execute()
+}
+
+// vpnV4APIAdapter adapts the v4 VpnAPI to the interface
+type vpnV4APIAdapter struct {
+ api v4client.VpnAPI
+}
+
+func (a *vpnV4APIAdapter) VpnL2vpnsList(ctx context.Context) interfaces.VpnL2vpnsListRequest {
+ return &vpnL2vpnsListRequestAdapter{req: a.api.VpnL2vpnsList(ctx)}
+}
+
+func (a *vpnV4APIAdapter) VpnL2vpnsCreate(ctx context.Context) interfaces.VpnL2vpnsCreateRequest {
+ return &vpnL2vpnsCreateRequestAdapter{req: a.api.VpnL2vpnsCreate(ctx)}
+}
+
+func (a *vpnV4APIAdapter) VpnL2vpnsUpdate(ctx context.Context, id int32) interfaces.VpnL2vpnsUpdateRequest {
+ return &vpnL2vpnsUpdateRequestAdapter{req: a.api.VpnL2vpnsUpdate(ctx, id)}
+}
+
+func (a *vpnV4APIAdapter) VpnL2vpnsDestroy(ctx context.Context, id int32) interfaces.VpnL2vpnsDestroyRequest {
+ return &vpnL2vpnsDestroyRequestAdapter{req: a.api.VpnL2vpnsDestroy(ctx, id)}
+}
+
type statusRetrieveRequestAdapter struct {
req v4client.ApiStatusRetrieveRequest
}
diff --git a/pkg/netbox/interfaces/netbox.go b/pkg/netbox/interfaces/netbox.go
index 9efa6b5c..b4e87f47 100644
--- a/pkg/netbox/interfaces/netbox.go
+++ b/pkg/netbox/interfaces/netbox.go
@@ -112,6 +112,37 @@ type IpamAPI interface {
IpamPrefixesDestroy(ctx context.Context, id int32) IpamPrefixesDestroyRequest
}
+type VpnL2vpnsListRequest interface {
+ Name(name []string) VpnL2vpnsListRequest
+ Type_(type_ []string) VpnL2vpnsListRequest
+ IdentifierGte(identifierGte []int32) VpnL2vpnsListRequest
+ IdentifierLte(identifierLte []int32) VpnL2vpnsListRequest
+ Limit(limit int32) VpnL2vpnsListRequest
+ Offset(offset int32) VpnL2vpnsListRequest
+ Execute() (*v4client.PaginatedL2VPNList, *http.Response, error)
+}
+
+type VpnL2vpnsCreateRequest interface {
+ WritableL2VPNRequest(writableL2VPNRequest v4client.WritableL2VPNRequest) VpnL2vpnsCreateRequest
+ Execute() (*v4client.L2VPN, *http.Response, error)
+}
+
+type VpnL2vpnsUpdateRequest interface {
+ WritableL2VPNRequest(writableL2VPNRequest v4client.WritableL2VPNRequest) VpnL2vpnsUpdateRequest
+ Execute() (*v4client.L2VPN, *http.Response, error)
+}
+
+type VpnL2vpnsDestroyRequest interface {
+ Execute() (*http.Response, error)
+}
+
+type VpnAPI interface {
+ VpnL2vpnsList(ctx context.Context) VpnL2vpnsListRequest
+ VpnL2vpnsCreate(ctx context.Context) VpnL2vpnsCreateRequest
+ VpnL2vpnsUpdate(ctx context.Context, id int32) VpnL2vpnsUpdateRequest
+ VpnL2vpnsDestroy(ctx context.Context, id int32) VpnL2vpnsDestroyRequest
+}
+
type APIStatusRetrieveRequest interface {
Execute() (map[string]interface{}, *http.Response, error)
}
diff --git a/pkg/netbox/models/vpn.go b/pkg/netbox/models/vpn.go
new file mode 100644
index 00000000..44363feb
--- /dev/null
+++ b/pkg/netbox/models/vpn.go
@@ -0,0 +1,34 @@
+/*
+Copyright 2026 Swisscom (Schweiz) AG.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+type L2VPN struct {
+ Name string `json:"name,omitempty"`
+ Slug string `json:"slug,omitempty"`
+ Type string `json:"type,omitempty"`
+ Identifier int64 `json:"identifier,omitempty"`
+ Id int64 `json:"id,omitempty"`
+ Metadata *NetboxMetadata `json:"metadata,omitempty"`
+}
+
+type L2VPNClaim struct {
+ Type string `json:"type,omitempty"`
+ Identifier int64 `json:"identifier,omitempty"`
+ IdentifierRangeStart int64 `json:"identifierRangeStart,omitempty"`
+ IdentifierRangeEnd int64 `json:"identifierRangeEnd,omitempty"`
+ Metadata *NetboxMetadata `json:"metadata,omitempty"`
+}
diff --git a/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/chainsaw-test.yaml
new file mode 100644
index 00000000..eb1564bd
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/chainsaw-test.yaml
@@ -0,0 +1,100 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-explicit-apply-update
+ annotations:
+ description: Tests if creation and update is successful for an explicit identifier
+spec:
+ steps:
+ - name: Apply CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim.yaml
+ - name: Check CR spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-explicit-apply-update
+ spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ type: "vxlan-evpn"
+ identifier: 5100001
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5100001
+ l2VPNName: l2vpnclaim-explicit-apply-update
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-explicit-apply-update
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-explicit-apply-update
+ spec:
+ comments: your comments
+ description: some description
+ name: l2vpnclaim-explicit-apply-update
+ type: vxlan-evpn
+ identifier: 5100001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ slug: l2vpnclaim-explicit-apply-update
+ - name: Update CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim-update.yaml
+ - name: Check CR spec and status after update
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-explicit-apply-update
+ spec:
+ tenant: "MY_TENANT"
+ description: "new description"
+ comments: "new comments"
+ type: "vxlan-evpn"
+ identifier: 5100001
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5100001
+ l2VPNName: l2vpnclaim-explicit-apply-update
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-explicit-apply-update
+ spec:
+ comments: new comments
+ description: new description
+ identifier: 5100001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-explicit-apply-update -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim-update.yaml b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim-update.yaml
new file mode 100644
index 00000000..90c3f097
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim-update.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-explicit-apply-update
+spec:
+ tenant: "MY_TENANT"
+ description: "new description"
+ comments: "new comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifier: 5100001
diff --git a/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim.yaml b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim.yaml
new file mode 100644
index 00000000..ec1ca1af
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-explicit-apply-update/netbox_v1_l2vpnclaim.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-explicit-apply-update
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifier: 5100001
diff --git a/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/chainsaw-test.yaml
new file mode 100644
index 00000000..acd771ef
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/chainsaw-test.yaml
@@ -0,0 +1,69 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-invalid-customfieldnotexisting
+ annotations:
+ description: Tests if reservation in NetBox fails when a custom field is not registered
+spec:
+ steps:
+ - name: Apply CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim.yaml
+ - name: Check CR spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-invalid-customfieldnotexisting
+ spec:
+ customFields:
+ justmade: "thisup"
+ status:
+ (conditions[?type == 'L2VPNAssigned']):
+ - status: 'True'
+ (conditions[?type == 'Ready']):
+ - status: 'False'
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ name: l2vpnclaim-invalid-customfieldnotexisting
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-invalid-customfieldnotexisting
+ spec:
+ customFields:
+ justmade: thisup
+ identifier: 5700001
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'False'
+ - assert:
+ resource:
+ apiVersion: v1
+ kind: Event
+ type: Warning
+ reason: FailedToReserveL2VPNInNetbox
+ source:
+ component: l2vpn-controller
+ message: "Failed to reserve L2VPN in NetBox: failed to reserve l2vpn: status 400, body: {\"__all__\":[\"Unknown field name 'justmade' in custom field data.\"]}, identifier: 5700001"
+ involvedObject:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ name: l2vpnclaim-invalid-customfieldnotexisting
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource and lease cleanup for preventing delays when using the same identifier range
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-invalid-customfieldnotexisting -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/netbox_v1_l2vpnclaim.yaml b/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/netbox_v1_l2vpnclaim.yaml
new file mode 100644
index 00000000..b1d75808
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-invalid-customfieldnotexisting/netbox_v1_l2vpnclaim.yaml
@@ -0,0 +1,14 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-invalid-customfieldnotexisting
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifier: 5700001
+ customFields:
+ justmade: "thisup"
diff --git a/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/chainsaw-test.yaml
new file mode 100644
index 00000000..412c0b1a
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/chainsaw-test.yaml
@@ -0,0 +1,45 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-invalid-tenant
+ annotations:
+ description: Tests if creation fails when the referenced tenant does not exist
+spec:
+ steps:
+ - name: Apply CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim.yaml
+ - name: Check CR spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-invalid-tenant
+ spec:
+ tenant: "nonexistingtenant"
+ status:
+ (conditions[?type == 'L2VPNAssigned']):
+ - status: 'False'
+ - assert:
+ resource:
+ apiVersion: v1
+ kind: Event
+ type: Warning
+ reason: L2VPNCRNotCreated
+ source:
+ component: l2vpn-claim-controller
+ message: "Failed to fetch new identifier from NetBox: failed to fetch tenant 'nonexistingtenant': not found"
+ involvedObject:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ name: l2vpnclaim-invalid-tenant
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource and lease cleanup for preventing delays when using the same identifier range
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-invalid-tenant -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/netbox_v1_l2vpnclaim.yaml b/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/netbox_v1_l2vpnclaim.yaml
new file mode 100644
index 00000000..02e13cb5
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-invalid-tenant/netbox_v1_l2vpnclaim.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-invalid-tenant
+spec:
+ tenant: "nonexistingtenant"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifierRangeStart: 5600001
+ identifierRangeEnd: 5600010
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/chainsaw-test.yaml
new file mode 100644
index 00000000..2884c642
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/chainsaw-test.yaml
@@ -0,0 +1,100 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-range-apply-update
+ annotations:
+ description: Tests if creation and update is successful for a range-based claim
+spec:
+ steps:
+ - name: Apply CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim.yaml
+ - name: Check CR spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-apply-update
+ spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ type: "vxlan"
+ identifierRangeStart: 5200001
+ identifierRangeEnd: 5200010
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5200001
+ l2VPNName: l2vpnclaim-range-apply-update
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-apply-update
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-range-apply-update
+ spec:
+ comments: your comments
+ description: some description
+ type: vxlan
+ identifier: 5200001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Update CR
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim-update.yaml
+ - name: Check CR spec and status after update
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-apply-update
+ spec:
+ tenant: "MY_TENANT"
+ description: "new description"
+ comments: "new comments"
+ type: "vxlan"
+ identifierRangeStart: 5200001
+ identifierRangeEnd: 5200010
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5200001
+ l2VPNName: l2vpnclaim-range-apply-update
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-apply-update
+ spec:
+ comments: new comments
+ description: new description
+ identifier: 5200001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource and lease cleanup for preventing delays when using the same identifier range
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-apply-update -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim-update.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim-update.yaml
new file mode 100644
index 00000000..e9499d78
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim-update.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-apply-update
+spec:
+ tenant: "MY_TENANT"
+ description: "new description"
+ comments: "new comments"
+ preserveInNetbox: false
+ type: "vxlan"
+ identifierRangeStart: 5200001
+ identifierRangeEnd: 5200010
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim.yaml
new file mode 100644
index 00000000..aaca614a
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-apply-update/netbox_v1_l2vpnclaim.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-apply-update
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan"
+ identifierRangeStart: 5200001
+ identifierRangeEnd: 5200010
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/chainsaw-test.yaml
new file mode 100644
index 00000000..a8668042
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/chainsaw-test.yaml
@@ -0,0 +1,103 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-range-exhausted
+ annotations:
+ description: Tests if creation fails once the identifier range is exhausted
+spec:
+ steps:
+ - name: Apply CR 1
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_1.yaml
+ - name: Check CR 1 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-exhausted-1
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5400001
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-exhausted-1
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ spec:
+ identifier: 5400001
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Apply CR 2
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_2.yaml
+ - name: Check CR 2 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-exhausted-2
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5400002
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-exhausted-2
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ spec:
+ identifier: 5400002
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Apply CR 3
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_3.yaml
+ - name: Check CR 3 spec and status and verify it fails
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-exhausted-3
+ status:
+ (conditions[?type == 'L2VPNAssigned']):
+ - status: 'False'
+ - assert:
+ resource:
+ apiVersion: v1
+ kind: Event
+ type: Warning
+ reason: L2VPNCRNotCreated
+ source:
+ component: l2vpn-claim-controller
+ message: "Failed to fetch new identifier from NetBox: l2vpn identifier range exhausted"
+ involvedObject:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ name: l2vpnclaim-range-exhausted-3
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource and lease cleanup for preventing delays when using the same identifier range
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-exhausted-1 -n $NAMESPACE
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-exhausted-2 -n $NAMESPACE
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-exhausted-3 -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_1.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_1.yaml
new file mode 100644
index 00000000..d751c6ff
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_1.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-exhausted-1
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifierRangeStart: 5400001
+ identifierRangeEnd: 5400002
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_2.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_2.yaml
new file mode 100644
index 00000000..5e1f7ea6
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_2.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-exhausted-2
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifierRangeStart: 5400001
+ identifierRangeEnd: 5400002
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_3.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_3.yaml
new file mode 100644
index 00000000..dce0b9e3
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-exhausted/netbox_v1_l2vpnclaim_3.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-exhausted-3
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifierRangeStart: 5400001
+ identifierRangeEnd: 5400002
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-restore/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-restore/chainsaw-test.yaml
new file mode 100644
index 00000000..9d23af64
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-restore/chainsaw-test.yaml
@@ -0,0 +1,154 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-range-restore
+ annotations:
+ description: Tests if deletion and re-creation of a range-based claim restores the same identifier instead of picking a new one
+spec:
+ steps:
+ - name: Apply CR 1
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_1.yaml
+ - name: Check CR 1 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ spec:
+ comments: your comments
+ description: some description
+ type: vxlan-evpn
+ identifierRangeStart: 5300001
+ identifierRangeEnd: 5300010
+ preserveInNetbox: true
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5300001
+ l2VPNName: l2vpnclaim-range-restore-1
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-range-restore-1
+ spec:
+ identifier: 5300001
+ preserveInNetbox: true
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Apply CR 2
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_2.yaml
+ - name: Check CR 2 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-restore-2
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5300002
+ l2VPNName: l2vpnclaim-range-restore-2
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-restore-2
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ spec:
+ identifier: 5300002
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Delete CR 1
+ description: delete CR 1 (we only delete CR 1, so if the restoration failed to claim, it will take the next available identifier which will be 5300003, not 5300001)
+ try:
+ - delete:
+ ref:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ name: l2vpnclaim-range-restore-1
+ - name: Apply CR 1 again and check if restored
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_1.yaml
+ - name: Check CR 1 spec and status and make sure it is restored
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5300001
+ l2VPNName: l2vpnclaim-range-restore-1
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-range-restore-1
+ spec:
+ identifier: 5300001
+ preserveInNetbox: true
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Set preserveInNetbox to false
+ description: Set preserveInNetbox to false to clean up the NetBox test instance
+ try:
+ - patch:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ spec:
+ preserveInNetbox: false
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-range-restore-1
+ status:
+ (conditions[?type == 'Ready']):
+ - observedGeneration: 2
+ status: 'True'
+ - name: Cleanup events and leases
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource and lease cleanup for preventing delays when using the same identifier range
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-restore-1 -n $NAMESPACE
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-range-restore-2 -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_1.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_1.yaml
new file mode 100644
index 00000000..864ccb48
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_1.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-restore-1
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: true
+ type: "vxlan-evpn"
+ identifierRangeStart: 5300001
+ identifierRangeEnd: 5300010
diff --git a/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_2.yaml b/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_2.yaml
new file mode 100644
index 00000000..9de4f421
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-range-restore/netbox_v1_l2vpnclaim_2.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-range-restore-2
+spec:
+ tenant: "MY_TENANT"
+ description: "some description"
+ comments: "your comments"
+ preserveInNetbox: false
+ type: "vxlan-evpn"
+ identifierRangeStart: 5300001
+ identifierRangeEnd: 5300010
diff --git a/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/chainsaw-test.yaml b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/chainsaw-test.yaml
new file mode 100644
index 00000000..e92768dc
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/chainsaw-test.yaml
@@ -0,0 +1,77 @@
+---
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: l2vpnclaim-update-ownerreference
+ annotations:
+ description: Tests if controller updates ownerReference if a non-Claim L2VPN existed before (e.g. from a velero backup)
+spec:
+ steps:
+ - name: Apply CR 1
+ try:
+ - apply:
+ file: netbox_v1_l2vpn_1.yaml
+ - name: Check non-claim CR 1 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-update-ownerreference-1
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Apply CR 1
+ try:
+ - apply:
+ file: netbox_v1_l2vpnclaim_1.yaml
+ - name: Check claim CR 1 spec and status
+ try:
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPNClaim
+ metadata:
+ name: l2vpnclaim-update-ownerreference-1
+ spec:
+ comments: your comments
+ description: some description
+ type: vxlan-evpn
+ identifier: 5500001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ identifier: 5500001
+ l2VPNName: l2vpnclaim-update-ownerreference-1
+ - assert:
+ resource:
+ apiVersion: netbox.dev/v1
+ kind: L2VPN
+ metadata:
+ name: l2vpnclaim-update-ownerreference-1
+ finalizers:
+ - l2vpn.netbox.dev/finalizer
+ ownerReferences:
+ - apiVersion: netbox.dev/v1
+ blockOwnerDeletion: true
+ controller: true
+ kind: L2VPNClaim
+ name: l2vpnclaim-update-ownerreference-1
+ spec:
+ comments: your comments
+ customFields:
+ netboxOperatorRestorationHash: af1597bb78b62cddb76419182d41e2e32f973111
+ description: some description
+ identifier: 5500001
+ tenant: MY_TENANT
+ status:
+ (conditions[?type == 'Ready']):
+ - status: 'True'
+ - name: Cleanup events
+ description: Events cleanup required to fix issues with failing tests that assert the wrong Error resource
+ cleanup:
+ - script:
+ content: |-
+ kubectl delete events --field-selector involvedObject.name=l2vpnclaim-update-ownerreference-1 -n $NAMESPACE
diff --git a/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpn_1.yaml b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpn_1.yaml
new file mode 100644
index 00000000..dd675b81
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpn_1.yaml
@@ -0,0 +1,14 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPN
+metadata:
+ name: l2vpnclaim-update-ownerreference-1
+spec:
+ comments: your comments
+ customFields:
+ netboxOperatorRestorationHash: af1597bb78b62cddb76419182d41e2e32f973111
+ description: some description
+ name: l2vpnclaim-update-ownerreference-1
+ type: vxlan-evpn
+ identifier: 5500001
+ tenant: MY_TENANT
diff --git a/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpnclaim_1.yaml b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpnclaim_1.yaml
new file mode 100644
index 00000000..f8d1e5da
--- /dev/null
+++ b/tests/e2e/l2vpn/l2vpnclaim-update-ownerreference/netbox_v1_l2vpnclaim_1.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: netbox.dev/v1
+kind: L2VPNClaim
+metadata:
+ name: l2vpnclaim-update-ownerreference-1
+spec:
+ comments: your comments
+ description: some description
+ type: vxlan-evpn
+ identifier: 5500001
+ preserveInNetbox: false
+ tenant: MY_TENANT