diff --git a/Makefile b/Makefile index 241f55f5..1b4f4d97 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,8 @@ endif # scaffolded by default. However, you might want to replace it to use other # tools. (i.e. podman) CONTAINER_TOOL ?= docker +# TARGET_PLATFORM defines the target platform for the manager image building. +TARGET_PLATFORM ?= linux/amd64 # Setting SHELL to bash allows bash commands to be executed by recipes. # Options are set to exit when a recipe line exits non-zero or a piped command fails. @@ -45,7 +47,7 @@ GO_PACKAGE_NAME_GOLANGCI_LINT := golangci-lint install-$(GO_PACKAGE_NAME_GOLANGCI_LINT): @if [ ! -x "$(GOBIN)/$(GO_PACKAGE_NAME_GOLANGCI_LINT)" ]; then \ echo "Installing $(GO_PACKAGE_NAME_GOLANGCI_LINT)..." ; \ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v1.60.3 ; \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest ; \ else \ echo "$(GO_PACKAGE_NAME_GOLANGCI_LINT) is installed" ; \ fi @@ -101,7 +103,7 @@ vet: ## Run go vet against code. .PHONY: lint lint: install-$(GO_PACKAGE_NAME_GOLANGCI_LINT) ## Run golangci-lint against code. - golangci-lint run --config tools/.golangci.yaml ./... + $(GOBIN)/golangci-lint run --config tools/.golangci.yaml ./... .PHONY: vulncheck vulncheck: install-$(GO_PACKAGE_NAME_GOVULNCHECK) ## Run govulncheck against code. @@ -129,7 +131,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build --platform $(TARGET_PLATFORM) -t ${IMG} . .PHONY: docker-build-local docker-build-local: ## Build docker image with the manager. diff --git a/README.md b/README.md index ed8d7bc6..82aa5b83 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 VLANs – 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). @@ -10,7 +10,7 @@ In this case, instead of disk storage, NetBox Operator dynamically allocates net ### Purpose This model ensures a declarative management of IP addressing and subnet allocation, with full NetBox integration. -The users will create claims (PrefixClaims & IPAddressClaims), and the NetBox Operator will resolve them into actual Prefixes and IPAddresses within a designated parent prefix. +The users will create claims (PrefixClaims, IPAddressClaims & VLANClaims), and the NetBox Operator will resolve them into actual Prefixes, IPAddresses and VLANs within a designated parent prefix or site. ![Figure 1: NetBox Operator High-Level Architecture](docs/netbox-operator-high-level-architecture.drawio.svg) @@ -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, and VLANClaim 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/api/v1/vlan_types.go b/api/v1/vlan_types.go new file mode 100644 index 00000000..07b14b6b --- /dev/null +++ b/api/v1/vlan_types.go @@ -0,0 +1,124 @@ +/* +Copyright 2024 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" +) + +// VlanSpec defines the desired state of Vlan +type VlanSpec struct { + // The unique VLAN ID (VID) + //+kubebuilder:validation:Required + //+kubebuilder:validation:Minimum=1 + //+kubebuilder:validation:Maximum=4094 + //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'vlanId' is immutable" + VlanId int `json:"vlanId"` + + // The desired name for the VLAN in NetBox + //+kubebuilder:validation:Required + Name string `json:"name"` + + // The NetBox Site where this VLAN should exist + //+kubebuilder:validation:Required + Site string `json:"site"` + + // The NetBox VLANGroup where this VLAN should be organized + //+optional + VlanGroup string `json:"vlanGroup,omitempty"` + + // Description that should be added to the resource in NetBox + //+optional + Description string `json:"description,omitempty"` + + // Comment that should be added to the resource in NetBox + //+optional + Comments string `json:"comments,omitempty"` + + // The NetBox Custom Fields that should be added to the resource in NetBox + //+optional + CustomFields map[string]string `json:"customFields,omitempty"` + + // Defines whether the Resource should be preserved in NetBox when the + // Kubernetes Resource is deleted. + //+optional + PreserveInNetbox bool `json:"preserveInNetbox,omitempty"` +} + +// VlanStatus defines the observed state of Vlan +type VlanStatus struct { + // The NetBox internal database ID of the created/managed VLAN + //+optional + VlanId int64 `json:"id,omitempty"` + + // The URL to the VLAN object in the NetBox UI + //+optional + VlanUrl string `json:"url,omitempty"` + + // Conditions represent the latest available observations of an object's state + //+optional + 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="VLAN ID",type=integer,JSONPath=`.spec.vlanId` +//+kubebuilder:printcolumn:name="NetBox ID",type=integer,JSONPath=`.status.id` +//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +//+kubebuilder:resource:shortName=vl + +// Vlan is the Schema for the vlans API +type Vlan struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VlanSpec `json:"spec,omitempty"` + Status VlanStatus `json:"status,omitempty"` +} + +func (v *Vlan) Conditions() *[]metav1.Condition { + return &v.Status.Conditions +} + +//+kubebuilder:object:root=true + +// VlanList contains a list of Vlan +type VlanList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Vlan `json:"items"` +} + +func init() { + register(&Vlan{}, &VlanList{}) +} + +var ConditionVlanReadyTrue = metav1.Condition{ + Type: "Ready", + Status: "True", + Reason: "VlanSynchronized", + Message: "VLAN was synchronized with NetBox", +} + +var ConditionVlanReadyFalse = metav1.Condition{ + Type: "Ready", + Status: "False", + Reason: "VlanSyncFailed", + Message: "Failed to synchronize VLAN with NetBox", +} diff --git a/api/v1/vlanclaim_types.go b/api/v1/vlanclaim_types.go new file mode 100644 index 00000000..2939f528 --- /dev/null +++ b/api/v1/vlanclaim_types.go @@ -0,0 +1,137 @@ +/* +Copyright 2024 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" +) + +// VLANClaimSpec defines the desired state of VLANClaim +type VLANClaimSpec struct { + // The unique VLAN ID (VID) for the NetBox VLAN. If not provided, the operator will claim an available VID. + //+optional + //+kubebuilder:validation:Minimum=1 + //+kubebuilder:validation:Maximum=4094 + VlanId int `json:"vlanId,omitempty"` + + // The desired name for the VLAN in NetBox. If not provided, the operator will generate one. + //+optional + Name string `json:"name,omitempty"` + + // The NetBox Site where this VLAN should exist + //+kubebuilder:validation:Required + Site string `json:"site"` + + // The NetBox VLANGroup where this VLAN should be organized. Required if vlanId is not provided. + //+optional + VlanGroup string `json:"vlanGroup,omitempty"` + + // Description that should be added to the resource in NetBox + //+optional + Description string `json:"description,omitempty"` + + // Comment that should be added to the resource in NetBox + //+optional + Comments string `json:"comments,omitempty"` + + // The NetBox Custom Fields that should be added to the resource in NetBox + //+optional + CustomFields map[string]string `json:"customFields,omitempty"` + + // Defines whether the Resource should be preserved in NetBox when the + // Kubernetes Resource is deleted. + //+optional + PreserveInNetbox bool `json:"preserveInNetbox,omitempty"` +} + +// VLANClaimStatus defines the observed state of VLANClaim +type VLANClaimStatus struct { + // The assigned VLAN ID (VID) + //+optional + VlanId int `json:"vlanId,omitempty"` + + // The name of the Vlan CR created by the VLANClaim Controller + //+optional + VlanName string `json:"vlanName,omitempty"` + + // Conditions represent the latest available observations of an object's state + //+optional + 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="VID",type=integer,JSONPath=`.status.vlanId` +//+kubebuilder:printcolumn:name="Vlan Name",type=string,JSONPath=`.status.vlanName` +//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +//+kubebuilder:resource:shortName=vlc + +// VLANClaim is the Schema for the vlanclaims API +type VLANClaim struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VLANClaimSpec `json:"spec,omitempty"` + Status VLANClaimStatus `json:"status,omitempty"` +} + +func (v *VLANClaim) Conditions() *[]metav1.Condition { + return &v.Status.Conditions +} + +//+kubebuilder:object:root=true + +// VLANClaimList contains a list of VLANClaim +type VLANClaimList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VLANClaim `json:"items"` +} + +func init() { + register(&VLANClaim{}, &VLANClaimList{}) +} + +var ConditionVlanClaimReadyTrue = metav1.Condition{ + Type: "Ready", + Status: "True", + Reason: "VlanResourceReady", + Message: "VLAN Resource is ready", +} + +var ConditionVlanClaimReadyFalse = metav1.Condition{ + Type: "Ready", + Status: "False", + Reason: "VlanResourceNotReady", + Message: "VLAN Resource is not ready", +} + +var ConditionVlanAssignedTrue = metav1.Condition{ + Type: "VlanAssigned", + Status: "True", + Reason: "VlanCRCreated", + Message: "VLAN VID assigned and Vlan CR created", +} + +var ConditionVlanAssignedFalse = metav1.Condition{ + Type: "VlanAssigned", + Status: "False", + Reason: "VlanCRNotCreated", + Message: "Failed to assign VID or create Vlan CR", +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 921a0f66..b31b45c4 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -662,3 +662,209 @@ func (in *PrefixStatus) DeepCopy() *PrefixStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VLANClaim) DeepCopyInto(out *VLANClaim) { + *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 VLANClaim. +func (in *VLANClaim) DeepCopy() *VLANClaim { + if in == nil { + return nil + } + out := new(VLANClaim) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VLANClaim) 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 *VLANClaimList) DeepCopyInto(out *VLANClaimList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VLANClaim, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLANClaimList. +func (in *VLANClaimList) DeepCopy() *VLANClaimList { + if in == nil { + return nil + } + out := new(VLANClaimList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VLANClaimList) 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 *VLANClaimSpec) DeepCopyInto(out *VLANClaimSpec) { + *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 VLANClaimSpec. +func (in *VLANClaimSpec) DeepCopy() *VLANClaimSpec { + if in == nil { + return nil + } + out := new(VLANClaimSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VLANClaimStatus) DeepCopyInto(out *VLANClaimStatus) { + *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 VLANClaimStatus. +func (in *VLANClaimStatus) DeepCopy() *VLANClaimStatus { + if in == nil { + return nil + } + out := new(VLANClaimStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Vlan) DeepCopyInto(out *Vlan) { + *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 Vlan. +func (in *Vlan) DeepCopy() *Vlan { + if in == nil { + return nil + } + out := new(Vlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Vlan) 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 *VlanList) DeepCopyInto(out *VlanList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Vlan, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VlanList. +func (in *VlanList) DeepCopy() *VlanList { + if in == nil { + return nil + } + out := new(VlanList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VlanList) 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 *VlanSpec) DeepCopyInto(out *VlanSpec) { + *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 VlanSpec. +func (in *VlanSpec) DeepCopy() *VlanSpec { + if in == nil { + return nil + } + out := new(VlanSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VlanStatus) DeepCopyInto(out *VlanStatus) { + *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 VlanStatus. +func (in *VlanStatus) DeepCopy() *VlanStatus { + if in == nil { + return nil + } + out := new(VlanStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index c27fbd57..94eb0a28 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -243,6 +243,26 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "IpRange") os.Exit(1) } + if err = (&controller.VlanReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventStatusRecorder: controller.NewEventStatusRecorder(mgr.GetEventRecorderFor("vlan-controller")), + NetboxClient: netboxCompositeClient, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Vlan") + os.Exit(1) + } + if err = (&controller.VLANClaimReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventStatusRecorder: controller.NewEventStatusRecorder(mgr.GetEventRecorderFor("vlan-claim-controller")), + NetboxClient: netboxCompositeClient, + OperatorNamespace: operatorNamespace, + RestConfig: mgr.GetConfig(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "VLANClaim") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/netbox.dev_vlanclaims.yaml b/config/crd/bases/netbox.dev_vlanclaims.yaml new file mode 100644 index 00000000..3f45cab9 --- /dev/null +++ b/config/crd/bases/netbox.dev_vlanclaims.yaml @@ -0,0 +1,166 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: vlanclaims.netbox.dev +spec: + group: netbox.dev + names: + kind: VLANClaim + listKind: VLANClaimList + plural: vlanclaims + shortNames: + - vlc + singular: vlanclaim + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.vlanId + name: VID + type: integer + - jsonPath: .status.vlanName + name: Vlan Name + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: VLANClaim is the Schema for the vlanclaims API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: VLANClaimSpec defines the desired state of VLANClaim + properties: + comments: + description: Comment that should be added to the resource in NetBox + type: string + customFields: + additionalProperties: + type: string + description: The NetBox Custom Fields that should be added to the + resource in NetBox + type: object + description: + description: Description that should be added to the resource in NetBox + type: string + name: + description: The desired name for the VLAN in NetBox. If not provided, + the operator will generate one. + type: string + preserveInNetbox: + description: |- + Defines whether the Resource should be preserved in NetBox when the + Kubernetes Resource is deleted. + type: boolean + site: + description: The NetBox Site where this VLAN should exist + type: string + vlanGroup: + description: The NetBox VLANGroup where this VLAN should be organized. + Required if vlanId is not provided. + type: string + vlanId: + description: The unique VLAN ID (VID) for the NetBox VLAN. If not + provided, the operator will claim an available VID. + maximum: 4094 + minimum: 1 + type: integer + required: + - site + type: object + status: + description: VLANClaimStatus defines the observed state of VLANClaim + 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 + vlanId: + description: The assigned VLAN ID (VID) + type: integer + vlanName: + description: The name of the Vlan CR created by the VLANClaim Controller + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/netbox.dev_vlans.yaml b/config/crd/bases/netbox.dev_vlans.yaml new file mode 100644 index 00000000..a02f275e --- /dev/null +++ b/config/crd/bases/netbox.dev_vlans.yaml @@ -0,0 +1,170 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: vlans.netbox.dev +spec: + group: netbox.dev + names: + kind: Vlan + listKind: VlanList + plural: vlans + shortNames: + - vl + singular: vlan + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.vlanId + name: VLAN ID + type: integer + - jsonPath: .status.id + name: NetBox ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: Vlan is the Schema for the vlans API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: VlanSpec defines the desired state of Vlan + properties: + comments: + description: Comment that should be added to the resource in NetBox + type: string + customFields: + additionalProperties: + type: string + description: The NetBox Custom Fields that should be added to the + resource in NetBox + type: object + description: + description: Description that should be added to the resource in NetBox + type: string + name: + description: The desired name for the VLAN in NetBox + type: string + preserveInNetbox: + description: |- + Defines whether the Resource should be preserved in NetBox when the + Kubernetes Resource is deleted. + type: boolean + site: + description: The NetBox Site where this VLAN should exist + type: string + vlanGroup: + description: The NetBox VLANGroup where this VLAN should be organized + type: string + vlanId: + description: The unique VLAN ID (VID) + maximum: 4094 + minimum: 1 + type: integer + x-kubernetes-validations: + - message: Field 'vlanId' is immutable + rule: self == oldSelf + required: + - name + - site + - vlanId + type: object + status: + description: VlanStatus defines the observed state of Vlan + 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 NetBox internal database ID of the created/managed + VLAN + format: int64 + type: integer + url: + description: The URL to the VLAN object in the NetBox UI + 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..e6ff72ce 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_vlanclaims.yaml +- bases/netbox.dev_vlans.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9ce32be3..40bb217f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -20,6 +20,8 @@ rules: - ipranges - prefixclaims - prefixes + - vlanclaims + - vlans verbs: - create - delete @@ -37,6 +39,8 @@ rules: - ipranges/finalizers - prefixclaims/finalizers - prefixes/finalizers + - vlanclaims/finalizers + - vlans/finalizers verbs: - update - apiGroups: @@ -48,6 +52,8 @@ rules: - ipranges/status - prefixclaims/status - prefixes/status + - vlanclaims/status + - vlans/status verbs: - get - patch diff --git a/config/samples/netbox_v1_vlan.yaml b/config/samples/netbox_v1_vlan.yaml new file mode 100644 index 00000000..17984408 --- /dev/null +++ b/config/samples/netbox_v1_vlan.yaml @@ -0,0 +1,13 @@ +--- + +apiVersion: netbox.dev/v1 +kind: Vlan +metadata: + name: vlan-sample +spec: + vlanId: 200 + name: static-vlan-sample + site: Site-1 + vlanGroup: Group-1 + description: "Static VLAN managed directly by Vlan CR" + preserveInNetbox: false diff --git a/config/samples/netbox_v1_vlanclaim.yaml b/config/samples/netbox_v1_vlanclaim.yaml new file mode 100644 index 00000000..3cc24f87 --- /dev/null +++ b/config/samples/netbox_v1_vlanclaim.yaml @@ -0,0 +1,13 @@ +--- + +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-sample +spec: + vlanId: 100 + name: static-vlan-100 + site: Site-1 + vlanGroup: Group-1 + description: "Sample VLAN Claim managed by NetBox Operator" + preserveInNetbox: false diff --git a/config/samples/netbox_v1_vlanclaim_dynamic.yaml b/config/samples/netbox_v1_vlanclaim_dynamic.yaml new file mode 100644 index 00000000..e340161b --- /dev/null +++ b/config/samples/netbox_v1_vlanclaim_dynamic.yaml @@ -0,0 +1,12 @@ +--- + +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-dynamic-sample +spec: + # vlanId is omitted - the operator will allocate the next available VID + site: Site-1 + vlanGroup: Group-1 + description: "Dynamic VLAN Claim - VID allocated automatically" + preserveInNetbox: false diff --git a/gen/mock_interfaces/netbox_mocks.go b/gen/mock_interfaces/netbox_mocks.go index c3bf755e..1fffd762 100644 --- a/gen/mock_interfaces/netbox_mocks.go +++ b/gen/mock_interfaces/netbox_mocks.go @@ -348,6 +348,106 @@ func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesUpdate(params, authInfo any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesUpdate", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesUpdate), varargs...) } +// IpamVlanGroupsList mocks base method. +func (m *MockIpamInterface) IpamVlanGroupsList(params *ipam.IpamVlanGroupsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlanGroupsListOK, error) { + m.ctrl.T.Helper() + varargs := []any{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "IpamVlanGroupsList", varargs...) + ret0, _ := ret[0].(*ipam.IpamVlanGroupsListOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IpamVlanGroupsList indicates an expected call of IpamVlanGroupsList. +func (mr *MockIpamInterfaceMockRecorder) IpamVlanGroupsList(params, authInfo any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamVlanGroupsList", reflect.TypeOf((*MockIpamInterface)(nil).IpamVlanGroupsList), varargs...) +} + +// IpamVlansCreate mocks base method. +func (m *MockIpamInterface) IpamVlansCreate(params *ipam.IpamVlansCreateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansCreateCreated, error) { + m.ctrl.T.Helper() + varargs := []any{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "IpamVlansCreate", varargs...) + ret0, _ := ret[0].(*ipam.IpamVlansCreateCreated) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IpamVlansCreate indicates an expected call of IpamVlansCreate. +func (mr *MockIpamInterfaceMockRecorder) IpamVlansCreate(params, authInfo any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamVlansCreate", reflect.TypeOf((*MockIpamInterface)(nil).IpamVlansCreate), varargs...) +} + +// IpamVlansDelete mocks base method. +func (m *MockIpamInterface) IpamVlansDelete(params *ipam.IpamVlansDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansDeleteNoContent, error) { + m.ctrl.T.Helper() + varargs := []any{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "IpamVlansDelete", varargs...) + ret0, _ := ret[0].(*ipam.IpamVlansDeleteNoContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IpamVlansDelete indicates an expected call of IpamVlansDelete. +func (mr *MockIpamInterfaceMockRecorder) IpamVlansDelete(params, authInfo any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamVlansDelete", reflect.TypeOf((*MockIpamInterface)(nil).IpamVlansDelete), varargs...) +} + +// IpamVlansList mocks base method. +func (m *MockIpamInterface) IpamVlansList(params *ipam.IpamVlansListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansListOK, error) { + m.ctrl.T.Helper() + varargs := []any{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "IpamVlansList", varargs...) + ret0, _ := ret[0].(*ipam.IpamVlansListOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IpamVlansList indicates an expected call of IpamVlansList. +func (mr *MockIpamInterfaceMockRecorder) IpamVlansList(params, authInfo any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamVlansList", reflect.TypeOf((*MockIpamInterface)(nil).IpamVlansList), varargs...) +} + +// IpamVlansUpdate mocks base method. +func (m *MockIpamInterface) IpamVlansUpdate(params *ipam.IpamVlansUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansUpdateOK, error) { + m.ctrl.T.Helper() + varargs := []any{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "IpamVlansUpdate", varargs...) + ret0, _ := ret[0].(*ipam.IpamVlansUpdateOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IpamVlansUpdate indicates an expected call of IpamVlansUpdate. +func (mr *MockIpamInterfaceMockRecorder) IpamVlansUpdate(params, authInfo any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamVlansUpdate", reflect.TypeOf((*MockIpamInterface)(nil).IpamVlansUpdate), varargs...) +} + // MockTenancyInterface is a mock of TenancyInterface interface. type MockTenancyInterface struct { ctrl *gomock.Controller diff --git a/internal/controller/expected_netboxmock_calls_test.go b/internal/controller/expected_netboxmock_calls_test.go index f9f0b526..f210e7c0 100644 --- a/internal/controller/expected_netboxmock_calls_test.go +++ b/internal/controller/expected_netboxmock_calls_test.go @@ -21,8 +21,10 @@ import ( "net/http" "github.com/go-test/deep" + "github.com/netbox-community/go-netbox/v3/netbox/client/dcim" "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" "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" "go.uber.org/mock/gomock" @@ -232,7 +234,7 @@ func mockIpamIPAddressesUpdateFail(ipamMock *mock_interfaces.MockIpamInterface, return &ipam.IpamIPAddressesUpdateOK{}, err } fmt.Printf("NETBOXMOCK\t ipam.IpamIPAddressesUpdate was called with expected input\n") - return &ipam.IpamIPAddressesUpdateOK{Payload: nil}, fmt.Errorf("ipam.IpamIpAddressesUpdate: mock error in netbox") + return &ipam.IpamIPAddressesUpdateOK{Payload: nil}, fmt.Errorf("ipam.IpamIPAddressesUpdate: mock error in netbox") }).MinTimes(1) } @@ -285,24 +287,104 @@ func mockTenancyTenancyTenantsList(tenancyMock *mock_interfaces.MockTenancyInter }).MinTimes(1) } +func mockVlansListResponse() *ipam.IpamVlansListOK { + count := int64(0) + return &ipam.IpamVlansListOK{ + Payload: &ipam.IpamVlansListOKBody{ + Count: &count, + Results: []*netboxModels.VLAN{}, + }, + } +} + +func mockVlanGroupsListResponse(name string, id int64) *ipam.IpamVlanGroupsListOK { + return &ipam.IpamVlanGroupsListOK{ + Payload: &ipam.IpamVlanGroupsListOKBody{ + Count: &[]int64{1}[0], + Results: []*netboxModels.VLANGroup{ + { + ID: id, + Name: &name, + Slug: &name, + }, + }, + }, + } +} + +func mockSitesListResponse(name string) *dcim.DcimSitesListOK { + return &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Count: &[]int64{1}[0], + Results: []*netboxModels.Site{ + { + ID: 1, // siteId is 2 in netbox_testdata_test.go + Name: &name, + Slug: &name, + }, + }, + }, + } +} + +func mockTenantsListResponse() *tenancy.TenancyTenantsListOK { + tenantName := "test-tenant" + return &tenancy.TenancyTenantsListOK{ + Payload: &tenancy.TenancyTenantsListOKBody{ + Count: &[]int64{1}[0], + Results: []*netboxModels.Tenant{ + { + ID: 1, + Name: &tenantName, + Slug: &tenantName, + }, + }, + }, + } +} + // ----------------------------- // Reset Mock Functions // ----------------------------- -func resetMockFunctions(ipamMockA *mock_interfaces.MockIpamInterface, ipamMockB *mock_interfaces.MockIpamInterface, tenancyMock *mock_interfaces.MockTenancyInterface) { - ipamMockA.EXPECT().IpamIPAddressesList(gomock.Any(), gomock.Any()).Times(0) - ipamMockA.EXPECT().IpamIPAddressesUpdate(gomock.Any(), gomock.Any(), nil).Times(0) - ipamMockA.EXPECT().IpamPrefixesList(gomock.Any(), gomock.Any()).Times(0) - ipamMockA.EXPECT().IpamPrefixesAvailableIpsList(gomock.Any(), gomock.Any()).Times(0) - ipamMockA.EXPECT().IpamIPAddressesDelete(gomock.Any(), nil).Times(0) - ipamMockA.EXPECT().IpamIPAddressesUpdate(gomock.Any(), nil).Times(0) - ipamMockA.EXPECT().IpamIPAddressesCreate(gomock.Any(), nil).Times(0) - ipamMockB.EXPECT().IpamIPAddressesList(gomock.Any(), gomock.Any()).Times(0) - ipamMockB.EXPECT().IpamIPAddressesUpdate(gomock.Any(), gomock.Any(), nil).Times(0) - ipamMockB.EXPECT().IpamPrefixesList(gomock.Any(), gomock.Any()).Times(0) - ipamMockB.EXPECT().IpamPrefixesAvailableIpsList(gomock.Any(), gomock.Any()).Times(0) - ipamMockB.EXPECT().IpamIPAddressesDelete(gomock.Any(), nil).Times(0) - ipamMockB.EXPECT().IpamIPAddressesUpdate(gomock.Any(), nil).Times(0) - ipamMockB.EXPECT().IpamIPAddressesCreate(gomock.Any(), nil).Times(0) - tenancyMock.EXPECT().TenancyTenantsList(gomock.Any(), nil).Times(0) +func resetMockFunctions( + ipamMockA *mock_interfaces.MockIpamInterface, + ipamMockB *mock_interfaces.MockIpamInterface, + ipamMockC *mock_interfaces.MockIpamInterface, + ipamMockD *mock_interfaces.MockIpamInterface, + tenancyMock *mock_interfaces.MockTenancyInterface, + dcimMock *mock_interfaces.MockDcimInterface, +) { + mocks := []*mock_interfaces.MockIpamInterface{ipamMockA, ipamMockB, ipamMockC, ipamMockD} + for _, m := range mocks { + m.EXPECT().IpamIPAddressesList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamIPAddressesUpdate(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesAvailableIpsList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamIPAddressesDelete(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamIPAddressesUpdate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamIPAddressesCreate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamVlansList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamVlansCreate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamVlansUpdate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamVlansDelete(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamVlanGroupsList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesAvailablePrefixesList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesCreate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesUpdate(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + m.EXPECT().IpamPrefixesDelete(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + } + tenancyMock.EXPECT().TenancyTenantsList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) + dcimMock.EXPECT().DcimSitesList(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil).Times(0) +} + +func resetAllMockFunctions( + ipamMockA *mock_interfaces.MockIpamInterface, + ipamMockB *mock_interfaces.MockIpamInterface, + ipamMockC *mock_interfaces.MockIpamInterface, + ipamMockD *mock_interfaces.MockIpamInterface, + tenancyMock *mock_interfaces.MockTenancyInterface, + dcimMock *mock_interfaces.MockDcimInterface, +) { + resetMockFunctions(ipamMockA, ipamMockB, ipamMockC, ipamMockD, tenancyMock, dcimMock) } diff --git a/internal/controller/ipaddress_controller_test.go b/internal/controller/ipaddress_controller_test.go index 3c9dac5b..9651c732 100644 --- a/internal/controller/ipaddress_controller_test.go +++ b/internal/controller/ipaddress_controller_test.go @@ -85,7 +85,7 @@ var _ = Describe("IpAddress Controller", Ordered, func() { AfterEach(func() { By("Resetting the mock controller") - resetMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, tenancyMock) + resetMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, ipamMockVlan, ipamMockVlanClaim, tenancyMock, dcimMock) }) DescribeTable("Reconciler (ip address CR without owner reference)", func( diff --git a/internal/controller/ipaddressclaim_controller_test.go b/internal/controller/ipaddressclaim_controller_test.go index 1367829b..187d5c14 100644 --- a/internal/controller/ipaddressclaim_controller_test.go +++ b/internal/controller/ipaddressclaim_controller_test.go @@ -50,7 +50,7 @@ var _ = Describe("IpAddressClaim Controller", Ordered, func() { AfterEach(func() { By("Resetting the mock controller") - resetMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, tenancyMock) + resetMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, ipamMockVlan, ipamMockVlanClaim, tenancyMock, dcimMock) }) DescribeTable("Reconciler (ip address claim CR)", func( diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index aafe0d7e..832e7a23 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -58,6 +58,9 @@ var mockIpamAPI *mock_interfaces.MockIpamAPI var mockIpamPrefixesListRequest *mock_interfaces.MockIpamPrefixesListRequest var ipamMockIpAddress *mock_interfaces.MockIpamInterface var ipamMockIpAddressClaim *mock_interfaces.MockIpamInterface +var ipamMockVlan *mock_interfaces.MockIpamInterface +var ipamMockVlanClaim *mock_interfaces.MockIpamInterface + var tenancyMock *mock_interfaces.MockTenancyInterface var dcimMock *mock_interfaces.MockDcimInterface var ctx context.Context @@ -112,6 +115,8 @@ var _ = BeforeSuite(func() { ipamMockIpAddress = mock_interfaces.NewMockIpamInterface(mockCtrl) ipamMockIpAddressClaim = mock_interfaces.NewMockIpamInterface(mockCtrl) + ipamMockVlan = mock_interfaces.NewMockIpamInterface(mockCtrl) + ipamMockVlanClaim = mock_interfaces.NewMockIpamInterface(mockCtrl) tenancyMock = mock_interfaces.NewMockTenancyInterface(mockCtrl) dcimMock = mock_interfaces.NewMockDcimInterface(mockCtrl) mockIpamAPI = mock_interfaces.NewMockIpamAPI(mockCtrl) @@ -155,6 +160,38 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&VlanReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + EventStatusRecorder: NewEventStatusRecorder(k8sManager.GetEventRecorderFor("vlan-controller")), + NetboxClient: api.NewNetboxCompositeClient( + &api.NetboxClientV3{ + Ipam: ipamMockVlan, + Tenancy: tenancyMock, + Dcim: dcimMock, + }, + &api.NetboxClientV4{IpamAPI: mockIpamAPI}, + ), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + err = (&VLANClaimReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + EventStatusRecorder: NewEventStatusRecorder(k8sManager.GetEventRecorderFor("vlan-claim-controller")), + NetboxClient: api.NewNetboxCompositeClient( + &api.NetboxClientV3{ + Ipam: ipamMockVlanClaim, + Tenancy: tenancyMock, + Dcim: dcimMock, + }, + &api.NetboxClientV4{IpamAPI: mockIpamAPI}, + ), + OperatorNamespace: OperatorNamespace, + RestConfig: k8sManager.GetConfig(), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() ctx, cancel = context.WithCancel(context.TODO()) diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 9b022ce3..31dce95e 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -110,6 +110,10 @@ func convertCIDRToLeaseLockName(cidr string) string { // section is still protected. const lockAcquireTimeout = 10 * time.Second +func convertVlanGroupToLeaseLockName(vlanGroup string) string { + return "vlangroup-" + strings.ReplaceAll(vlanGroup, " ", "-") +} + func generateManagedCustomFieldsAnnotation(customFields map[string]string) (string, error) { if customFields == nil { customFields = make(map[string]string) diff --git a/internal/controller/vlan_controller.go b/internal/controller/vlan_controller.go new file mode 100644 index 00000000..129eefa3 --- /dev/null +++ b/internal/controller/vlan_controller.go @@ -0,0 +1,196 @@ +/* +Copyright 2024 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" + "strconv" + + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + 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" + + apismeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + 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 ( + VlanFinalizerName = "vlan.netbox.dev/finalizer" + ManagedByNetboxOperatorValue = "netbox-operator" +) + +// VlanReconciler reconciles a Vlan object +type VlanReconciler struct { + client.Client + Scheme *runtime.Scheme + NetboxClient *api.NetboxCompositeClient + EventStatusRecorder *EventStatusRecorder +} + +//+kubebuilder:rbac:groups=netbox.dev,resources=vlans,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=netbox.dev,resources=vlans/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=netbox.dev,resources=vlans/finalizers,verbs=update + +func (r *VlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + o := &netboxv1.Vlan{} + if err := r.Get(ctx, req.NamespacedName, o); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Handle Finalizer + if o.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(o, VlanFinalizerName) { + controllerutil.AddFinalizer(o, VlanFinalizerName) + if err := r.Update(ctx, o); err != nil { + return ctrl.Result{}, err + } + } + } else { + if controllerutil.ContainsFinalizer(o, VlanFinalizerName) { + if !o.Spec.PreserveInNetbox && o.Status.VlanId != 0 { + if err := r.NetboxClient.DeleteVlan(o.Status.VlanId); err != nil { + return ctrl.Result{}, err + } + } + controllerutil.RemoveFinalizer(o, VlanFinalizerName) + if err := r.Update(ctx, o); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + // NetBox Operation + vlanModel := &models.Vlan{ + VlanId: o.Spec.VlanId, + Name: o.Spec.Name, + Metadata: &models.NetboxMetadata{ + Site: o.Spec.Site, + Description: o.Spec.Description, + Comments: o.Spec.Comments, + Custom: o.Spec.CustomFields, + }, + } + if vlanModel.Metadata.Custom == nil { + vlanModel.Metadata.Custom = make(map[string]string) + } + vlanModel.Metadata.Custom["managed_by"] = ManagedByNetboxOperatorValue + + existingVlans, err := r.NetboxClient.GetVlan(vlanModel) + if err != nil { + return ctrl.Result{}, err + } + + var nbVlan *netboxModels.VLAN + vid := int64(o.Spec.VlanId) + writableVlan := &netboxModels.WritableVLAN{ + Vid: &vid, + Name: &o.Spec.Name, + Description: o.Spec.Description, + Comments: o.Spec.Comments, + } + + // Handle Site + site, err := r.NetboxClient.GetSiteDetails(o.Spec.Site) + if err != nil { + return ctrl.Result{}, err + } + writableVlan.Site = &site.Id + + // Handle VlanGroup + if o.Spec.VlanGroup != "" { + vlanGroup, err := r.NetboxClient.GetVlanGroupDetails(o.Spec.VlanGroup) + if err != nil { + return ctrl.Result{}, err + } + writableVlan.Group = &vlanGroup.Id + } + + // Handle Custom Fields + cf := make(map[string]interface{}) + for k, v := range vlanModel.Metadata.Custom { + cf[k] = v + } + writableVlan.CustomFields = cf + + if len(existingVlans.Payload.Results) == 0 { + nbVlan, err = r.NetboxClient.CreateVlan(writableVlan) + } else { + existing := existingVlans.Payload.Results[0] + // Check ownership or update if managed + managedBy, ok := existing.CustomFields.(map[string]interface{})["managed_by"] + if !ok || managedBy != ManagedByNetboxOperatorValue { + logger.Info("taking ownership of unmanaged VLAN", "vlan", o.Spec.VlanId) + } + nbVlan, err = r.NetboxClient.UpdateVlan(existing.ID, writableVlan) + } + + if err != nil { + r.updateStatus(ctx, o, false, "NetBoxError", err.Error()) + return ctrl.Result{}, err + } + + // Update Status + o.Status.VlanId = nbVlan.ID + o.Status.VlanUrl = config.GetBaseUrl() + "/ipam/vlans/" + strconv.FormatInt(nbVlan.ID, 10) + + apismeta.SetStatusCondition(&o.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Synced", + Message: "VLAN successfully synchronized to NetBox", + LastTransitionTime: metav1.Now(), + }) + + if err := r.Status().Update(ctx, o); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *VlanReconciler) updateStatus(ctx context.Context, o *netboxv1.Vlan, ready bool, reason, message string) { + status := metav1.ConditionFalse + if ready { + status = metav1.ConditionTrue + } + apismeta.SetStatusCondition(&o.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: metav1.Now(), + }) + if err := r.Status().Update(ctx, o); err != nil { + log.FromContext(ctx).Error(err, "unable to update Vlan status") + } +} + +func (r *VlanReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&netboxv1.Vlan{}). + Complete(r) +} diff --git a/internal/controller/vlan_controller_test.go b/internal/controller/vlan_controller_test.go new file mode 100644 index 00000000..64bafd47 --- /dev/null +++ b/internal/controller/vlan_controller_test.go @@ -0,0 +1,91 @@ +/* +Copyright 2024 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" + "time" + + "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + netboxv1 "github.com/netbox-community/netbox-operator/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("Vlan Controller", func() { + const ( + VlanName = "static-vlan" + VlanNamespace = "default" + VlanId = 200 + VlanGroupName = "test-group" + SiteName = "test-site" + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When creating a Vlan resource", func() { + It("Should sync it to NetBox", func() { + By("Defining a new Vlan") + vlan := &netboxv1.Vlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: VlanName, + Namespace: VlanNamespace, + }, + Spec: netboxv1.VlanSpec{ + VlanId: VlanId, + VlanGroup: VlanGroupName, + Site: SiteName, + Name: "my-static-vlan", + }, + } + + // Reset mocks + resetAllMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, ipamMockVlan, ipamMockVlanClaim, tenancyMock, dcimMock) + + // Mock NetBox interactions + ipamMockVlan.EXPECT().IpamVlansList(gomock.Any(), gomock.Any()).Return(mockVlansListResponse(), nil).AnyTimes() + ipamMockVlan.EXPECT().IpamVlansCreate(gomock.Any(), gomock.Any()).Return(&ipam.IpamVlansCreateCreated{Payload: &netboxModels.VLAN{ID: 2, Name: &vlan.Spec.Name, Vid: &[]int64{int64(VlanId)}[0]}}, nil).AnyTimes() + + // Mock VLAN Group details + ipamMockVlan.EXPECT().IpamVlanGroupsList(gomock.Any(), gomock.Any()).Return(mockVlanGroupsListResponse(VlanGroupName, 1), nil).AnyTimes() + + // Mock Site and Tenant + dcimMock.EXPECT().DcimSitesList(gomock.Any(), gomock.Any()).Return(mockSitesListResponse(SiteName), nil).AnyTimes() + tenancyMock.EXPECT().TenancyTenantsList(gomock.Any(), gomock.Any()).Return(mockTenantsListResponse(), nil).AnyTimes() + + By("Creating the Vlan in Kubernetes") + ctx := context.Background() + Expect(k8sClient.Create(ctx, vlan)).Should(Succeed()) + + By("Checking if the Vlan status was updated") + fetchedVlan := &netboxv1.Vlan{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: VlanName, Namespace: VlanNamespace}, fetchedVlan) + if err != nil { + return false + } + return fetchedVlan.Status.VlanId != 0 + }, timeout, interval).Should(BeTrue()) + + Expect(fetchedVlan.Status.VlanId).Should(Equal(int64(2))) + }) + }) +}) diff --git a/internal/controller/vlanclaim_controller.go b/internal/controller/vlanclaim_controller.go new file mode 100644 index 00000000..00356d48 --- /dev/null +++ b/internal/controller/vlanclaim_controller.go @@ -0,0 +1,229 @@ +/* +Copyright 2024 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/sha256" + "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/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" +) + +// VLANClaimReconciler reconciles a VLANClaim object +type VLANClaimReconciler struct { + client.Client + Scheme *runtime.Scheme + NetboxClient *api.NetboxCompositeClient + EventStatusRecorder *EventStatusRecorder + OperatorNamespace string + RestConfig *rest.Config +} + +//+kubebuilder:rbac:groups=netbox.dev,resources=vlanclaims,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=netbox.dev,resources=vlanclaims/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=netbox.dev,resources=vlanclaims/finalizers,verbs=update +//+kubebuilder:rbac:groups=netbox.dev,resources=vlans,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +func (r *VLANClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + debugLogger := logger.V(4) + + logger.Info("reconcile loop started") + + /* 0. check if the matching VLANClaim object exists */ + o := &netboxv1.VLANClaim{} + if err := r.Get(ctx, req.NamespacedName, o); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // if being deleted + if !o.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + // Set ready to false initially + if apismeta.FindStatusCondition(o.Status.Conditions, "Ready") == nil { + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanClaimReadyFalse, corev1.EventTypeNormal, nil) + } + + // 1. check if matching Vlan object already exists + vlan := &netboxv1.Vlan{} + vlanName := o.Name + vlanLookupKey := types.NamespacedName{ + Name: vlanName, + Namespace: o.Namespace, + } + + err := r.Get(ctx, vlanLookupKey, vlan) + if err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + + debugLogger.Info("vlan object matching vlan claim was not found, creating new vlan object") + + // 2. check if lease for vlan group is available + leaseLockerNSN := types.NamespacedName{ + Name: convertVlanGroupToLeaseLockName(o.Spec.VlanGroup), + Namespace: r.OperatorNamespace, + } + ll, err := leaselocker.NewLeaseLocker(r.RestConfig, leaseLockerNSN, req.Namespace+"/"+vlanName) + if err != nil { + return ctrl.Result{}, err + } + + lockCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // 3. try to lock lease for vlan group + locked := ll.TryLock(lockCtx) + if !locked { + r.EventStatusRecorder.Recorder().Eventf(o, corev1.EventTypeWarning, "FailedToLockVlanGroup", "failed to lock vlan group %s", o.Spec.VlanGroup) + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil + } + debugLogger.Info(fmt.Sprintf("successfully locked vlan group %s", o.Spec.VlanGroup)) + + // 4. try to reclaim vlan + h := generateVlanRestorationHash(o) + vlanModel, err := r.NetboxClient.RestoreExistingVlanByHash(h) + if err != nil { + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanAssignedFalse, corev1.EventTypeWarning, err) + return ctrl.Result{Requeue: true}, nil + } + + if vlanModel == nil { + // vlan cannot be restored from netbox, assign new one + if o.Spec.VlanId != 0 { + vlanModel = &models.Vlan{VlanId: o.Spec.VlanId} + } else { + vlanModel, err = r.NetboxClient.GetAvailableVlanByClaim(&models.VLANClaim{ + VlanGroup: o.Spec.VlanGroup, + Metadata: &models.NetboxMetadata{ + Site: o.Spec.Site, + }, + }) + if err != nil { + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanAssignedFalse, corev1.EventTypeWarning, err) + return ctrl.Result{Requeue: true}, nil + } + } + debugLogger.Info(fmt.Sprintf("assigned vlan vid: %d", vlanModel.VlanId)) + } + + // 6.a create the Vlan object + vlanResource := generateVlanFromVlanClaim(o, vlanModel.VlanId) + if err = controllerutil.SetControllerReference(o, vlanResource, r.Scheme); err != nil { + return ctrl.Result{}, err + } + + if err = r.Create(ctx, vlanResource); err != nil { + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanAssignedFalse, corev1.EventTypeWarning, err) + return ctrl.Result{}, err + } + + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanAssignedTrue, corev1.EventTypeNormal, nil) + } else { + // 6.b update fields of Vlan object + debugLogger.Info("update vlan resource") + updatedVlanSpec := generateVlanSpec(o, vlan.Spec.VlanId) + _, err = ctrl.CreateOrUpdate(ctx, r.Client, vlan, func() error { + vlan.Spec.Name = updatedVlanSpec.Name + vlan.Spec.Site = updatedVlanSpec.Site + vlan.Spec.VlanGroup = updatedVlanSpec.VlanGroup + vlan.Spec.CustomFields = updatedVlanSpec.CustomFields + vlan.Spec.Comments = updatedVlanSpec.Comments + vlan.Spec.Description = updatedVlanSpec.Description + vlan.Spec.PreserveInNetbox = updatedVlanSpec.PreserveInNetbox + return controllerutil.SetControllerReference(o, vlan, r.Scheme) + }) + if err != nil { + return ctrl.Result{}, err + } + } + + // 7. update VLANClaim Ready status + if apismeta.IsStatusConditionTrue(vlan.Status.Conditions, "Ready") { + o.Status.VlanId = vlan.Spec.VlanId + o.Status.VlanName = vlan.Name + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanClaimReadyTrue, corev1.EventTypeNormal, nil) + } else { + r.EventStatusRecorder.Report(ctx, o, netboxv1.ConditionVlanClaimReadyFalse, corev1.EventTypeWarning, nil) + return ctrl.Result{Requeue: true}, nil + } + + logger.Info("reconcile loop finished") + return ctrl.Result{}, nil +} + +func generateVlanRestorationHash(o *netboxv1.VLANClaim) string { + h := sha256.New() + h.Write([]byte(o.Namespace)) + h.Write([]byte(o.Name)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func generateVlanFromVlanClaim(o *netboxv1.VLANClaim, vid int) *netboxv1.Vlan { + return &netboxv1.Vlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + Namespace: o.Namespace, + }, + Spec: generateVlanSpec(o, vid), + } +} + +func generateVlanSpec(o *netboxv1.VLANClaim, vid int) netboxv1.VlanSpec { + name := o.Spec.Name + if name == "" { + name = o.Name + } + return netboxv1.VlanSpec{ + VlanId: vid, + Name: name, + Site: o.Spec.Site, + VlanGroup: o.Spec.VlanGroup, + Description: o.Spec.Description, + Comments: o.Spec.Comments, + CustomFields: o.Spec.CustomFields, + PreserveInNetbox: o.Spec.PreserveInNetbox, + } +} + +func (r *VLANClaimReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&netboxv1.VLANClaim{}). + Owns(&netboxv1.Vlan{}). + Complete(r) +} diff --git a/internal/controller/vlanclaim_controller_test.go b/internal/controller/vlanclaim_controller_test.go new file mode 100644 index 00000000..d893d5ac --- /dev/null +++ b/internal/controller/vlanclaim_controller_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2024 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" + "time" + + "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + netboxv1 "github.com/netbox-community/netbox-operator/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("VLANClaim Controller", func() { + const ( + VlanClaimName = "test-vlan-claim" + VlanClaimNamespace = "default" + VlanId = 100 + VlanGroupName = "test-group" + SiteName = "test-site" + timeout = time.Second * 10 + duration = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When creating a VLANClaim", func() { + It("Should create a Vlan resource and update its status", func() { + By("Defining a new VLANClaim") + vlanClaim := &netboxv1.VLANClaim{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "netbox.dev/v1", + Kind: "VLANClaim", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: VlanClaimName, + Namespace: VlanClaimNamespace, + }, + Spec: netboxv1.VLANClaimSpec{ + VlanId: VlanId, + VlanGroup: VlanGroupName, + Site: SiteName, + Name: "my-vlan", + }, + } + + // Reset mocks + resetAllMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, ipamMockVlan, ipamMockVlanClaim, tenancyMock, dcimMock) + + // Mock NetBox interactions for VLANClaim (dynamic allocation / restoration) + // For this test, let's assume restoration finds nothing and we use the provided VlanId + ipamMockVlanClaim.EXPECT().IpamVlansList(gomock.Any(), gomock.Any()).Return(mockVlansListResponse(), nil).AnyTimes() + ipamMockVlanClaim.EXPECT().IpamVlansList(gomock.Any(), gomock.Any(), gomock.Any()).Return(mockVlansListResponse(), nil).AnyTimes() + + // Mock VLAN Group details for both reconcilers + ipamMockVlanClaim.EXPECT().IpamVlanGroupsList(gomock.Any(), gomock.Any()).Return(mockVlanGroupsListResponse(VlanGroupName, 1), nil).AnyTimes() + ipamMockVlan.EXPECT().IpamVlanGroupsList(gomock.Any(), gomock.Any()).Return(mockVlanGroupsListResponse(VlanGroupName, 1), nil).AnyTimes() + + // Mock Site and Tenant (shared mocks) + dcimMock.EXPECT().DcimSitesList(gomock.Any(), gomock.Any()).Return(mockSitesListResponse(SiteName), nil).AnyTimes() + tenancyMock.EXPECT().TenancyTenantsList(gomock.Any(), gomock.Any()).Return(mockTenantsListResponse(), nil).AnyTimes() + + // Mock Vlan creation/update for the Vlan controller + ipamMockVlan.EXPECT().IpamVlansList(gomock.Any(), gomock.Any()).Return(mockVlansListResponse(), nil).AnyTimes() + ipamMockVlan.EXPECT().IpamVlansCreate(gomock.Any(), gomock.Any()).Return(&ipam.IpamVlansCreateCreated{Payload: &netboxModels.VLAN{ID: 1, Name: &vlanClaim.Spec.Name, Vid: &[]int64{int64(VlanId)}[0]}}, nil).AnyTimes() + ipamMockVlan.EXPECT().IpamVlansUpdate(gomock.Any(), gomock.Any(), gomock.Any()).Return(&ipam.IpamVlansUpdateOK{Payload: &netboxModels.VLAN{ID: 1, Name: &vlanClaim.Spec.Name, Vid: &[]int64{int64(VlanId)}[0]}}, nil).AnyTimes() + + By("Creating the VLANClaim in Kubernetes") + ctx := context.Background() + Expect(k8sClient.Create(ctx, vlanClaim)).Should(Succeed()) + + By("Checking if the Vlan resource was created") + vlanLookupKey := types.NamespacedName{Name: VlanClaimName, Namespace: VlanClaimNamespace} + createdVlan := &netboxv1.Vlan{} + Eventually(func() bool { + err := k8sClient.Get(ctx, vlanLookupKey, createdVlan) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(createdVlan.Spec.VlanId).Should(Equal(VlanId)) + Expect(createdVlan.Spec.Name).Should(Equal("my-vlan")) + Expect(createdVlan.Spec.Site).Should(Equal(SiteName)) + Expect(createdVlan.Spec.VlanGroup).Should(Equal(VlanGroupName)) + + By("Mocking Vlan controller status update") + // In a real integration test, the Vlan controller would update the Vlan status. + // Since we registered both in suite_test, this should happen automatically if we mock correctly. + + By("Checking if the VLANClaim status was updated") + fetchedClaim := &netboxv1.VLANClaim{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: VlanClaimName, Namespace: VlanClaimNamespace}, fetchedClaim) + if err != nil { + return false + } + return fetchedClaim.Status.VlanName == VlanClaimName + }, timeout, interval).Should(BeTrue()) + }) + }) + + AfterEach(func() { + resetAllMockFunctions(ipamMockIpAddress, ipamMockIpAddressClaim, ipamMockVlan, ipamMockVlanClaim, tenancyMock, dcimMock) + + // Clean up + vlanClaim := &netboxv1.VLANClaim{} + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: VlanClaimName, Namespace: VlanClaimNamespace}, vlanClaim) + if err == nil { + Expect(k8sClient.Delete(context.Background(), vlanClaim)).Should(Succeed()) + } + }) +}) diff --git a/kind/job/kustomization.orig.yaml b/kind/job/kustomization.orig.yaml index 716fe282..68fcafcd 100644 --- a/kind/job/kustomization.orig.yaml +++ b/kind/job/kustomization.orig.yaml @@ -1,11 +1,10 @@ ---- resources: - - load-data-job.yaml +- load-data-job.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - - name: ghcr.io/zalando/spilo-16 - newName: ghcr.io/zalando/spilo-16 +- name: ghcr.io/zalando/spilo-16 + newName: ghcr.io/zalando/spilo-16 patches: - - path: sql-env-patch.yaml +- path: sql-env-patch.yaml diff --git a/pkg/netbox/api/site.go b/pkg/netbox/api/site.go index 91fe94ec..7b8b8726 100644 --- a/pkg/netbox/api/site.go +++ b/pkg/netbox/api/site.go @@ -23,6 +23,10 @@ import ( "github.com/netbox-community/netbox-operator/pkg/netbox/utils" ) +func (c *NetboxCompositeClient) GetSiteDetails(name string) (*models.Site, error) { + return c.getSiteDetails(name) +} + func (c *NetboxCompositeClient) getSiteDetails(name string) (*models.Site, error) { request := dcim.NewDcimSitesListParams().WithName(&name) response, err := c.clientV3.Dcim.DcimSitesList(request, nil) diff --git a/pkg/netbox/api/vlan.go b/pkg/netbox/api/vlan.go new file mode 100644 index 00000000..9ab7ae22 --- /dev/null +++ b/pkg/netbox/api/vlan.go @@ -0,0 +1,109 @@ +/* +Copyright 2024 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 ( + "fmt" + "net/http" + + "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + "github.com/netbox-community/netbox-operator/pkg/netbox/models" + "github.com/netbox-community/netbox-operator/pkg/netbox/utils" +) + +func (c *NetboxCompositeClient) GetVlan(vlan *models.Vlan) (*ipam.IpamVlansListOK, error) { + vidStr := fmt.Sprintf("%d", vlan.VlanId) + requestVlan := ipam.NewIpamVlansListParams(). + WithVid(&vidStr) + + if vlan.Metadata != nil { + if vlan.Metadata.Site != "" { + siteDetails, err := c.getSiteDetails(vlan.Metadata.Site) + if err != nil { + return nil, err + } + siteIdStr := fmt.Sprintf("%d", siteDetails.Id) + requestVlan = requestVlan.WithSiteID(&siteIdStr) + } + } + + responseVlan, err := c.clientV3.Ipam.IpamVlansList(requestVlan, nil) + if err != nil { + return nil, utils.NetboxError("failed to fetch Vlan details", err) + } + + return responseVlan, nil +} + +func (c *NetboxCompositeClient) CreateVlan(vlan *netboxModels.WritableVLAN) (*netboxModels.VLAN, error) { + requestCreateVlan := ipam.NewIpamVlansCreateParams(). + WithDefaults(). + WithData(vlan) + responseCreateVlan, err := c.clientV3.Ipam.IpamVlansCreate(requestCreateVlan, nil) + if err != nil { + return nil, utils.NetboxError("failed to create Vlan", err) + } + return responseCreateVlan.Payload, nil +} + +func (c *NetboxCompositeClient) UpdateVlan(vlanId int64, vlan *netboxModels.WritableVLAN) (*netboxModels.VLAN, error) { + requestUpdateVlan := ipam.NewIpamVlansUpdateParams(). + WithDefaults(). + WithData(vlan). + WithID(vlanId) + responseUpdateVlan, err := c.clientV3.Ipam.IpamVlansUpdate(requestUpdateVlan, nil) + if err != nil { + return nil, utils.NetboxError("failed to update Vlan", err) + } + return responseUpdateVlan.Payload, nil +} + +func (c *NetboxCompositeClient) DeleteVlan(vlanId int64) error { + requestDeleteVlan := ipam.NewIpamVlansDeleteParams().WithID(vlanId) + _, err := c.clientV3.Ipam.IpamVlansDelete(requestDeleteVlan, nil) + if err != nil { + switch typedErr := err.(type) { + case *ipam.IpamVlansDeleteDefault: + if typedErr.IsCode(http.StatusNotFound) { + return nil + } + return utils.NetboxError("Failed to delete vlan from Netbox", err) + default: + return utils.NetboxError("Failed to delete vlan from Netbox", err) + } + } + return nil +} + +func (c *NetboxCompositeClient) GetVlanGroupDetails(vlanGroupName string) (*models.VlanGroup, error) { + requestVlanGroup := ipam.NewIpamVlanGroupsListParams().WithName(&vlanGroupName) + responseVlanGroup, err := c.clientV3.Ipam.IpamVlanGroupsList(requestVlanGroup, nil) + if err != nil { + return nil, utils.NetboxError("failed to fetch VlanGroup details", err) + } + + if len(responseVlanGroup.Payload.Results) == 0 { + return nil, fmt.Errorf("vlangroup %s not found in Netbox", vlanGroupName) + } + + return &models.VlanGroup{ + Id: responseVlanGroup.Payload.Results[0].ID, + Name: *responseVlanGroup.Payload.Results[0].Name, + Slug: *responseVlanGroup.Payload.Results[0].Slug, + }, nil +} diff --git a/pkg/netbox/api/vlan_claim.go b/pkg/netbox/api/vlan_claim.go new file mode 100644 index 00000000..220c749e --- /dev/null +++ b/pkg/netbox/api/vlan_claim.go @@ -0,0 +1,97 @@ +/* +Copyright 2024 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 ( + "fmt" + + "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" + "github.com/netbox-community/netbox-operator/pkg/config" + "github.com/netbox-community/netbox-operator/pkg/netbox/models" +) + +func (c *NetboxCompositeClient) RestoreExistingVlanByHash(hash string) (*models.Vlan, error) { + customVlanSearch := newQueryFilterOperation(nil, []CustomFieldEntry{ + { + key: config.GetOperatorConfig().NetboxRestorationHashFieldName, + value: hash, + }, + }) + list, err := c.clientV3.Ipam.IpamVlansList(ipam.NewIpamVlansListParams(), nil, customVlanSearch) + if err != nil { + return nil, err + } + + if list.Payload.Count != nil && *list.Payload.Count == 0 { + return nil, nil + } + + if len(list.Payload.Results) != 1 { + return nil, fmt.Errorf("incorrect number of restoration results for VLAN, number of results: %v", len(list.Payload.Results)) + } + + res := list.Payload.Results[0] + vid := 0 + if res.Vid != nil { + vid = int(*res.Vid) + } + return &models.Vlan{ + VlanId: vid, + Name: *res.Name, + }, nil +} + +// GetAvailableVlanByClaim searches for an available VID in NetBox matching VLANClaim requirements +func (c *NetboxCompositeClient) GetAvailableVlanByClaim(vlanClaim *models.VLANClaim) (*models.Vlan, error) { + vlanGroup, err := c.GetVlanGroupDetails(vlanClaim.VlanGroup) + if err != nil { + return nil, err + } + + vlanGroupIdStr := fmt.Sprintf("%d", vlanGroup.Id) + params := ipam.NewIpamVlansListParams().WithGroupID(&vlanGroupIdStr) + + existingVlans, err := c.clientV3.Ipam.IpamVlansList(params, nil) + if err != nil { + return nil, err + } + + usedVids := make(map[int]bool) + for _, v := range existingVlans.Payload.Results { + if v.Vid != nil { + usedVids[int(*v.Vid)] = true + } + } + + // Find first available VID between 1 and 4094 + // TODO: Respect VlanGroup min/max if we can fetch them + allocatedVid := -1 + for i := 1; i <= 4094; i++ { + if !usedVids[i] { + allocatedVid = i + break + } + } + + if allocatedVid == -1 { + return nil, fmt.Errorf("no available VIDs found in VlanGroup %s", vlanClaim.VlanGroup) + } + + return &models.Vlan{ + VlanId: allocatedVid, + }, nil +} diff --git a/pkg/netbox/interfaces/netbox.go b/pkg/netbox/interfaces/netbox.go index 9efa6b5c..3459e13b 100644 --- a/pkg/netbox/interfaces/netbox.go +++ b/pkg/netbox/interfaces/netbox.go @@ -46,6 +46,12 @@ type IpamInterface interface { IpamIPRangesUpdate(params *ipam.IpamIPRangesUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesUpdateOK, error) IpamIPRangesDelete(params *ipam.IpamIPRangesDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesDeleteNoContent, error) IpamIPRangesAvailableIpsList(params *ipam.IpamIPRangesAvailableIpsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesAvailableIpsListOK, error) + + IpamVlansList(params *ipam.IpamVlansListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansListOK, error) + IpamVlansCreate(params *ipam.IpamVlansCreateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansCreateCreated, error) + IpamVlansUpdate(params *ipam.IpamVlansUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansUpdateOK, error) + IpamVlansDelete(params *ipam.IpamVlansDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlansDeleteNoContent, error) + IpamVlanGroupsList(params *ipam.IpamVlanGroupsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamVlanGroupsListOK, error) } type TenancyInterface interface { diff --git a/pkg/netbox/models/ipam.go b/pkg/netbox/models/ipam.go index 60948e10..ab358a1a 100644 --- a/pkg/netbox/models/ipam.go +++ b/pkg/netbox/models/ipam.go @@ -71,3 +71,22 @@ type IpRangeClaim struct { Size int `json:"size,omitempty"` Metadata *NetboxMetadata `json:"metadata,omitempty"` } + +type Vlan struct { + VlanId int `json:"vlanId,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Metadata *NetboxMetadata `json:"metadata,omitempty"` +} + +type VlanGroup struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` +} + +type VLANClaim struct { + VlanId int `json:"vlanId,omitempty"` + VlanGroup string `json:"vlanGroup,omitempty"` + Metadata *NetboxMetadata `json:"metadata,omitempty"` +} diff --git a/tests/e2e/vlan/vlan-apply-update/chainsaw-test.yaml b/tests/e2e/vlan/vlan-apply-update/chainsaw-test.yaml new file mode 100644 index 00000000..5b0f3269 --- /dev/null +++ b/tests/e2e/vlan/vlan-apply-update/chainsaw-test.yaml @@ -0,0 +1,39 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vlan-apply-update +spec: + steps: + - name: Apply Vlan + try: + - apply: + file: netbox_v1_vlan.yaml + - name: Assert Vlan Ready + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: Vlan + metadata: + name: vlan-apply-update + status: + (conditions[?type == 'Ready']): + - status: 'True' + - name: Update Vlan + try: + - apply: + file: netbox_v1_vlan-update.yaml + - name: Assert Vlan Updated + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: Vlan + metadata: + name: vlan-apply-update + spec: + name: vlan-test-apply-update-modified + description: "Updated description" + status: + (conditions[?type == 'Ready']): + - status: 'True' diff --git a/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan-update.yaml b/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan-update.yaml new file mode 100644 index 00000000..f444faad --- /dev/null +++ b/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan-update.yaml @@ -0,0 +1,10 @@ +apiVersion: netbox.dev/v1 +kind: Vlan +metadata: + name: vlan-apply-update +spec: + vlanId: 201 + name: vlan-test-apply-update-modified + site: MY_SITE + description: "Updated description" + preserveInNetbox: false diff --git a/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan.yaml b/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan.yaml new file mode 100644 index 00000000..abfd557a --- /dev/null +++ b/tests/e2e/vlan/vlan-apply-update/netbox_v1_vlan.yaml @@ -0,0 +1,10 @@ +apiVersion: netbox.dev/v1 +kind: Vlan +metadata: + name: vlan-apply-update +spec: + vlanId: 201 + name: vlan-test-apply-update + site: MY_SITE + description: "Initial description" + preserveInNetbox: false diff --git a/tests/e2e/vlan/vlanclaim-dynamic-apply-update/chainsaw-test.yaml b/tests/e2e/vlan/vlanclaim-dynamic-apply-update/chainsaw-test.yaml new file mode 100644 index 00000000..249da592 --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-dynamic-apply-update/chainsaw-test.yaml @@ -0,0 +1,31 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vlanclaim-dynamic-apply-update +spec: + steps: + - name: Apply dynamic VLANClaim + try: + - apply: + file: netbox_v1_vlanclaim.yaml + - name: Assert VLANClaim and Vlan Ready with assigned VID + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-dyn-unique + status: + (conditions[?type == 'Ready']): + - status: 'True' + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: Vlan + metadata: + name: vlanclaim-dyn-unique + status: + (conditions[?type == 'Ready']): + - status: 'True' + diff --git a/tests/e2e/vlan/vlanclaim-dynamic-apply-update/netbox_v1_vlanclaim.yaml b/tests/e2e/vlan/vlanclaim-dynamic-apply-update/netbox_v1_vlanclaim.yaml new file mode 100644 index 00000000..4f9d061b --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-dynamic-apply-update/netbox_v1_vlanclaim.yaml @@ -0,0 +1,9 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-dyn-unique +spec: + name: dynamic-vlan-test + site: MY_SITE + description: "Dynamic VID allocation test" + preserveInNetbox: false diff --git a/tests/e2e/vlan/vlanclaim-invalid-site/chainsaw-test.yaml b/tests/e2e/vlan/vlanclaim-invalid-site/chainsaw-test.yaml new file mode 100644 index 00000000..9e7ff79b --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-invalid-site/chainsaw-test.yaml @@ -0,0 +1,27 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vlanclaim-invalid-site +spec: + steps: + - name: Apply invalid VLANClaim + try: + - apply: + file: netbox_v1_vlanclaim.yaml + - name: Assert VLANClaim Failure status + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-invalid-site + status: + (conditions[?type == 'Ready']): + - status: 'False' + reason: VlanResourceNotReady + - name: Cleanup events + cleanup: + - script: + content: |- + kubectl delete events --field-selector involvedObject.name=vlanclaim-invalid-site -n $NAMESPACE diff --git a/tests/e2e/vlan/vlanclaim-invalid-site/netbox_v1_vlanclaim.yaml b/tests/e2e/vlan/vlanclaim-invalid-site/netbox_v1_vlanclaim.yaml new file mode 100644 index 00000000..bf8cbc86 --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-invalid-site/netbox_v1_vlanclaim.yaml @@ -0,0 +1,8 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-invalid-site +spec: + site: NON-EXISTENT-SITE + description: "Invalid site test" + preserveInNetbox: false diff --git a/tests/e2e/vlan/vlanclaim-restore/chainsaw-test.yaml b/tests/e2e/vlan/vlanclaim-restore/chainsaw-test.yaml new file mode 100644 index 00000000..5ef36b3a --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-restore/chainsaw-test.yaml @@ -0,0 +1,77 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vlanclaim-restore +spec: + steps: + - name: Apply VlanClaim 1 + try: + - apply: + file: netbox_v1_vlanclaim_1.yaml + - name: Assert VlanClaim 1 Ready + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-rest-1 + status: + (conditions[?type == 'Ready']): + - status: 'True' + - name: Delete VlanClaim 1 + try: + - delete: + ref: + apiVersion: netbox.dev/v1 + kind: VLANClaim + name: vlanclaim-rest-1 + - name: Apply VlanClaim 2 + try: + - apply: + file: netbox_v1_vlanclaim_2.yaml + - name: Assert VlanClaim 2 Ready + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-rest-2 + status: + (conditions[?type == 'Ready']): + - status: 'True' + - name: Restore VlanClaim 1 + try: + - apply: + file: netbox_v1_vlanclaim_1.yaml + - name: Assert VlanClaim 1 Restored + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-rest-1 + status: + (conditions[?type == 'Ready']): + - status: 'True' + - name: Cleanup + try: + - patch: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-rest-1 + spec: + preserveInNetbox: false + - patch: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-rest-2 + spec: + preserveInNetbox: false + diff --git a/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_1.yaml b/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_1.yaml new file mode 100644 index 00000000..4437e04a --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_1.yaml @@ -0,0 +1,8 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-rest-1 +spec: + site: MY_SITE + description: "Restoration unique - Claim 1" + preserveInNetbox: true diff --git a/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_2.yaml b/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_2.yaml new file mode 100644 index 00000000..d4a9a26e --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-restore/netbox_v1_vlanclaim_2.yaml @@ -0,0 +1,8 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-rest-2 +spec: + site: MY_SITE + description: "Restoration unique - Claim 2" + preserveInNetbox: true diff --git a/tests/e2e/vlan/vlanclaim-static-apply-update/chainsaw-test.yaml b/tests/e2e/vlan/vlanclaim-static-apply-update/chainsaw-test.yaml new file mode 100644 index 00000000..ec36699e --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-static-apply-update/chainsaw-test.yaml @@ -0,0 +1,61 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: vlanclaim-static-apply-update +spec: + steps: + - name: Apply static VLANClaim + try: + - apply: + file: netbox_v1_vlanclaim.yaml + - name: Assert VLANClaim and Vlan Ready + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-stat-unique + status: + (conditions[?type == 'Ready']): + - status: 'True' + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: Vlan + metadata: + name: vlanclaim-stat-unique + spec: + vlanId: 102 + status: + (conditions[?type == 'Ready']): + - status: 'True' + - name: Update static VLANClaim + try: + - apply: + file: netbox_v1_vlanclaim-update.yaml + - name: Assert VLANClaim and Vlan Updated + try: + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: VLANClaim + metadata: + name: vlanclaim-stat-unique + spec: + description: "Updated claim description" + status: + (conditions[?type == 'Ready']): + - status: 'True' + - assert: + resource: + apiVersion: netbox.dev/v1 + kind: Vlan + metadata: + name: vlanclaim-stat-unique + spec: + description: "Updated claim description" + status: + (conditions[?type == 'Ready']): + - status: 'True' + diff --git a/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim-update.yaml b/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim-update.yaml new file mode 100644 index 00000000..4d88a415 --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim-update.yaml @@ -0,0 +1,10 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-stat-unique +spec: + vlanId: 102 + name: static-vlan-102 + site: MY_SITE + description: "Updated claim description" + preserveInNetbox: false diff --git a/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim.yaml b/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim.yaml new file mode 100644 index 00000000..826641fc --- /dev/null +++ b/tests/e2e/vlan/vlanclaim-static-apply-update/netbox_v1_vlanclaim.yaml @@ -0,0 +1,10 @@ +apiVersion: netbox.dev/v1 +kind: VLANClaim +metadata: + name: vlanclaim-stat-unique +spec: + vlanId: 102 + name: static-vlan-102 + site: MY_SITE + description: "Initial claim description" + preserveInNetbox: false