From f26127e3f00aec4813b03234eded82a8405a62df Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 18:30:56 +0300 Subject: [PATCH 01/13] chore: add tags --- api/v1/prefix_types.go | 8 ++++ api/v1/tag_types.go | 11 +++++ api/v1/zz_generated.deepcopy.go | 20 +++++++++ config/crd/bases/netbox.dev_prefixes.yaml | 20 +++++++++ pkg/netbox/api/prefix.go | 11 +++++ pkg/netbox/api/tags.go | 55 +++++++++++++++++++++++ pkg/netbox/interfaces/netbox.go | 1 + pkg/netbox/models/ipam.go | 7 +++ 8 files changed, 133 insertions(+) create mode 100644 api/v1/tag_types.go create mode 100644 pkg/netbox/api/tags.go diff --git a/api/v1/prefix_types.go b/api/v1/prefix_types.go index baad5af4..fd9f6115 100644 --- a/api/v1/prefix_types.go +++ b/api/v1/prefix_types.go @@ -42,6 +42,14 @@ type PrefixSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the resource in NetBox. + // Each tag may contain either the `name` or `slug` field (one of them is required). + // Example: + // - name: tag1 + // - slug: tag2 + //+kubebuilder:validation:XValidation:rule="has(self.name) || has(self.slug)",message="One of the fields `name` or `slug` must be set" + Tags []Tag `json:"tags,omitempty"` + // The NetBox Custom Fields that should be added to the resource in NetBox. // Note that currently only Text Type is supported (GitHub #129) // More info on NetBox Custom Fields: diff --git a/api/v1/tag_types.go b/api/v1/tag_types.go new file mode 100644 index 00000000..910243a7 --- /dev/null +++ b/api/v1/tag_types.go @@ -0,0 +1,11 @@ +package v1 + +type Tag struct { + // +optional + // Name of the tag + Name string `json:"name,omitempty"` + + // +optional + // Slug of the tag + Slug string `json:"slug,omitempty"` +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 99e78261..08dea929 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -619,6 +619,11 @@ func (in *PrefixList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrefixSpec) DeepCopyInto(out *PrefixSpec) { *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) @@ -659,3 +664,18 @@ 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 *Tag) DeepCopyInto(out *Tag) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Tag. +func (in *Tag) DeepCopy() *Tag { + if in == nil { + return nil + } + out := new(Tag) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/netbox.dev_prefixes.yaml b/config/crd/bases/netbox.dev_prefixes.yaml index fba59720..07924fd5 100644 --- a/config/crd/bases/netbox.dev_prefixes.yaml +++ b/config/crd/bases/netbox.dev_prefixes.yaml @@ -112,6 +112,26 @@ spec: x-kubernetes-validations: - message: Field 'site' is required once set rule: self == oldSelf || self != '' + tags: + description: |- + A list of tags that will be assigned to the resource in NetBox. + Each tag may contain either the `name` or `slug` field (one of them is required). + Example: + - name: tag1 + - slug: tag2 + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + type: array + x-kubernetes-validations: + - message: One of the fields `name` or `slug` must be set + rule: has(self.name) || has(self.slug) tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/pkg/netbox/api/prefix.go b/pkg/netbox/api/prefix.go index 1c15830a..84bc34ae 100644 --- a/pkg/netbox/api/prefix.go +++ b/pkg/netbox/api/prefix.go @@ -67,6 +67,17 @@ func (r *NetboxClient) ReserveOrUpdatePrefix(prefix *models.Prefix) (*netboxMode desiredPrefix.Site = &siteDetails.Id } + if prefix.Metadata != nil && len(prefix.Metadata.Tags) > 0 { + desiredPrefix.Tags = []*netboxModels.NestedTag{} + for _, tag := range prefix.Metadata.Tags { + tagDetails, err := r.GetTagDetails(tag.Name, tag.Slug) + if err != nil { + return nil, err + } + desiredPrefix.Tags = append(desiredPrefix.Tags, &netboxModels.NestedTag{ID: tagDetails.Id, Name: &tagDetails.Name, Slug: &tagDetails.Slug}) + } + } + // create prefix since it doesn't exist if len(responsePrefix.Payload.Results) == 0 { return r.CreatePrefix(desiredPrefix) diff --git a/pkg/netbox/api/tags.go b/pkg/netbox/api/tags.go new file mode 100644 index 00000000..a028980c --- /dev/null +++ b/pkg/netbox/api/tags.go @@ -0,0 +1,55 @@ +/* +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 ( + "github.com/netbox-community/go-netbox/v3/netbox/client/extras" + + "github.com/netbox-community/netbox-operator/pkg/netbox/models" + "github.com/netbox-community/netbox-operator/pkg/netbox/utils" +) + +func (r *NetboxClient) GetTagDetails(name string, slug string) (*models.Tag, error) { + var request *extras.ExtrasTagsListParams + if name != "" { + request = extras.NewExtrasTagsListParams().WithName(&name) + } + if slug != "" { + request = extras.NewExtrasTagsListParams().WithSlug(&slug) + } + + if name == "" && slug == "" { + return nil, utils.NetboxError("either name or slug must be provided to fetch Tag details", nil) + } + // response, err := r.Tags.ExtrasTagsList(request, nil) + response, err := r.Extras.ExtrasTagsList(request, nil) + if err != nil { + return nil, utils.NetboxError("failed to fetch Tag details", err) + } + + if len(response.Payload.Results) == 0 { + return nil, utils.NetboxNotFoundError("tag '" + name + "/" + slug + "'") + } + + tag := response.Payload.Results[0] + return &models.Tag{ + Id: tag.ID, + Name: *tag.Name, + Slug: *tag.Slug, + }, nil + +} diff --git a/pkg/netbox/interfaces/netbox.go b/pkg/netbox/interfaces/netbox.go index 852891fb..0c7f4c79 100644 --- a/pkg/netbox/interfaces/netbox.go +++ b/pkg/netbox/interfaces/netbox.go @@ -50,6 +50,7 @@ type TenancyInterface interface { type ExtrasInterface interface { ExtrasCustomFieldsList(params *extras.ExtrasCustomFieldsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...extras.ClientOption) (*extras.ExtrasCustomFieldsListOK, error) + ExtrasTagsList(params *extras.ExtrasTagsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...extras.ClientOption) (*extras.ExtrasTagsListOK, error) } type DcimInterface interface { diff --git a/pkg/netbox/models/ipam.go b/pkg/netbox/models/ipam.go index bfe40304..e35ac4f8 100644 --- a/pkg/netbox/models/ipam.go +++ b/pkg/netbox/models/ipam.go @@ -22,6 +22,12 @@ type Tenant struct { Slug string `json:"slug,omitempty"` } +type Tag struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` +} + type Site struct { Id int64 `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -35,6 +41,7 @@ type NetboxMetadata struct { Region string `json:"region,omitempty"` Site string `json:"site,omitempty"` Tenant string `json:"tenant,omitempty"` + Tags []Tag `json:"tags,omitempty"` } type IPAddress struct { From 7bf39e938ef3376320a0ce916bda5f5307831345 Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 18:49:23 +0300 Subject: [PATCH 02/13] fix: add tests --- gen/mock_interfaces/netbox_mocks.go | 135 +++++++++++++++------------- pkg/netbox/api/tags_test.go | 120 +++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 60 deletions(-) create mode 100644 pkg/netbox/api/tags_test.go diff --git a/gen/mock_interfaces/netbox_mocks.go b/gen/mock_interfaces/netbox_mocks.go index d49403aa..d8ed0bed 100644 --- a/gen/mock_interfaces/netbox_mocks.go +++ b/gen/mock_interfaces/netbox_mocks.go @@ -5,7 +5,6 @@ // // mockgen -destination gen/mock_interfaces/netbox_mocks.go -source=pkg/netbox/interfaces/netbox.go // - // Package mock_interfaces is a generated GoMock package. package mock_interfaces @@ -13,18 +12,17 @@ import ( reflect "reflect" runtime "github.com/go-openapi/runtime" + gomock "go.uber.org/mock/gomock" dcim "github.com/netbox-community/go-netbox/v3/netbox/client/dcim" extras "github.com/netbox-community/go-netbox/v3/netbox/client/extras" ipam "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" tenancy "github.com/netbox-community/go-netbox/v3/netbox/client/tenancy" - gomock "go.uber.org/mock/gomock" ) // MockIpamInterface is a mock of IpamInterface interface. type MockIpamInterface struct { ctrl *gomock.Controller recorder *MockIpamInterfaceMockRecorder - isgomock struct{} } // MockIpamInterfaceMockRecorder is the mock recorder for MockIpamInterface. @@ -47,7 +45,7 @@ func (m *MockIpamInterface) EXPECT() *MockIpamInterfaceMockRecorder { // IpamIPAddressesCreate mocks base method. func (m *MockIpamInterface) IpamIPAddressesCreate(params *ipam.IpamIPAddressesCreateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPAddressesCreateCreated, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -58,16 +56,16 @@ func (m *MockIpamInterface) IpamIPAddressesCreate(params *ipam.IpamIPAddressesCr } // IpamIPAddressesCreate indicates an expected call of IpamIPAddressesCreate. -func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesCreate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesCreate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPAddressesCreate", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPAddressesCreate), varargs...) } // IpamIPAddressesDelete mocks base method. func (m *MockIpamInterface) IpamIPAddressesDelete(params *ipam.IpamIPAddressesDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPAddressesDeleteNoContent, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -78,16 +76,16 @@ func (m *MockIpamInterface) IpamIPAddressesDelete(params *ipam.IpamIPAddressesDe } // IpamIPAddressesDelete indicates an expected call of IpamIPAddressesDelete. -func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesDelete(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesDelete(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPAddressesDelete", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPAddressesDelete), varargs...) } // IpamIPAddressesList mocks base method. func (m *MockIpamInterface) IpamIPAddressesList(params *ipam.IpamIPAddressesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPAddressesListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -98,16 +96,16 @@ func (m *MockIpamInterface) IpamIPAddressesList(params *ipam.IpamIPAddressesList } // IpamIPAddressesList indicates an expected call of IpamIPAddressesList. -func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPAddressesList", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPAddressesList), varargs...) } // IpamIPAddressesUpdate mocks base method. func (m *MockIpamInterface) IpamIPAddressesUpdate(params *ipam.IpamIPAddressesUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPAddressesUpdateOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -118,16 +116,16 @@ func (m *MockIpamInterface) IpamIPAddressesUpdate(params *ipam.IpamIPAddressesUp } // IpamIPAddressesUpdate indicates an expected call of IpamIPAddressesUpdate. -func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesUpdate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPAddressesUpdate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPAddressesUpdate", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPAddressesUpdate), varargs...) } // IpamIPRangesAvailableIpsList mocks base method. func (m *MockIpamInterface) IpamIPRangesAvailableIpsList(params *ipam.IpamIPRangesAvailableIpsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesAvailableIpsListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -138,16 +136,16 @@ func (m *MockIpamInterface) IpamIPRangesAvailableIpsList(params *ipam.IpamIPRang } // IpamIPRangesAvailableIpsList indicates an expected call of IpamIPRangesAvailableIpsList. -func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesAvailableIpsList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesAvailableIpsList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPRangesAvailableIpsList", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPRangesAvailableIpsList), varargs...) } // IpamIPRangesCreate mocks base method. func (m *MockIpamInterface) IpamIPRangesCreate(params *ipam.IpamIPRangesCreateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesCreateCreated, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -158,16 +156,16 @@ func (m *MockIpamInterface) IpamIPRangesCreate(params *ipam.IpamIPRangesCreatePa } // IpamIPRangesCreate indicates an expected call of IpamIPRangesCreate. -func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesCreate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesCreate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPRangesCreate", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPRangesCreate), varargs...) } // IpamIPRangesDelete mocks base method. func (m *MockIpamInterface) IpamIPRangesDelete(params *ipam.IpamIPRangesDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesDeleteNoContent, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -178,16 +176,16 @@ func (m *MockIpamInterface) IpamIPRangesDelete(params *ipam.IpamIPRangesDeletePa } // IpamIPRangesDelete indicates an expected call of IpamIPRangesDelete. -func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesDelete(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesDelete(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPRangesDelete", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPRangesDelete), varargs...) } // IpamIPRangesList mocks base method. func (m *MockIpamInterface) IpamIPRangesList(params *ipam.IpamIPRangesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -198,16 +196,16 @@ func (m *MockIpamInterface) IpamIPRangesList(params *ipam.IpamIPRangesListParams } // IpamIPRangesList indicates an expected call of IpamIPRangesList. -func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPRangesList", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPRangesList), varargs...) } // IpamIPRangesUpdate mocks base method. func (m *MockIpamInterface) IpamIPRangesUpdate(params *ipam.IpamIPRangesUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamIPRangesUpdateOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -218,16 +216,16 @@ func (m *MockIpamInterface) IpamIPRangesUpdate(params *ipam.IpamIPRangesUpdatePa } // IpamIPRangesUpdate indicates an expected call of IpamIPRangesUpdate. -func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesUpdate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamIPRangesUpdate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamIPRangesUpdate", reflect.TypeOf((*MockIpamInterface)(nil).IpamIPRangesUpdate), varargs...) } // IpamPrefixesAvailableIpsList mocks base method. func (m *MockIpamInterface) IpamPrefixesAvailableIpsList(params *ipam.IpamPrefixesAvailableIpsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesAvailableIpsListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -238,16 +236,16 @@ func (m *MockIpamInterface) IpamPrefixesAvailableIpsList(params *ipam.IpamPrefix } // IpamPrefixesAvailableIpsList indicates an expected call of IpamPrefixesAvailableIpsList. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesAvailableIpsList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesAvailableIpsList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesAvailableIpsList", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesAvailableIpsList), varargs...) } // IpamPrefixesAvailablePrefixesList mocks base method. func (m *MockIpamInterface) IpamPrefixesAvailablePrefixesList(params *ipam.IpamPrefixesAvailablePrefixesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesAvailablePrefixesListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -258,16 +256,16 @@ func (m *MockIpamInterface) IpamPrefixesAvailablePrefixesList(params *ipam.IpamP } // IpamPrefixesAvailablePrefixesList indicates an expected call of IpamPrefixesAvailablePrefixesList. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesAvailablePrefixesList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesAvailablePrefixesList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesAvailablePrefixesList", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesAvailablePrefixesList), varargs...) } // IpamPrefixesCreate mocks base method. func (m *MockIpamInterface) IpamPrefixesCreate(params *ipam.IpamPrefixesCreateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesCreateCreated, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -278,16 +276,16 @@ func (m *MockIpamInterface) IpamPrefixesCreate(params *ipam.IpamPrefixesCreatePa } // IpamPrefixesCreate indicates an expected call of IpamPrefixesCreate. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesCreate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesCreate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesCreate", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesCreate), varargs...) } // IpamPrefixesDelete mocks base method. func (m *MockIpamInterface) IpamPrefixesDelete(params *ipam.IpamPrefixesDeleteParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesDeleteNoContent, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -298,16 +296,16 @@ func (m *MockIpamInterface) IpamPrefixesDelete(params *ipam.IpamPrefixesDeletePa } // IpamPrefixesDelete indicates an expected call of IpamPrefixesDelete. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesDelete(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesDelete(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesDelete", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesDelete), varargs...) } // IpamPrefixesList mocks base method. func (m *MockIpamInterface) IpamPrefixesList(params *ipam.IpamPrefixesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -318,16 +316,16 @@ func (m *MockIpamInterface) IpamPrefixesList(params *ipam.IpamPrefixesListParams } // IpamPrefixesList indicates an expected call of IpamPrefixesList. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesList", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesList), varargs...) } // IpamPrefixesUpdate mocks base method. func (m *MockIpamInterface) IpamPrefixesUpdate(params *ipam.IpamPrefixesUpdateParams, authInfo runtime.ClientAuthInfoWriter, opts ...ipam.ClientOption) (*ipam.IpamPrefixesUpdateOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -338,9 +336,9 @@ func (m *MockIpamInterface) IpamPrefixesUpdate(params *ipam.IpamPrefixesUpdatePa } // IpamPrefixesUpdate indicates an expected call of IpamPrefixesUpdate. -func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesUpdate(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesUpdate(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IpamPrefixesUpdate", reflect.TypeOf((*MockIpamInterface)(nil).IpamPrefixesUpdate), varargs...) } @@ -348,7 +346,6 @@ func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesUpdate(params, authInfo any type MockTenancyInterface struct { ctrl *gomock.Controller recorder *MockTenancyInterfaceMockRecorder - isgomock struct{} } // MockTenancyInterfaceMockRecorder is the mock recorder for MockTenancyInterface. @@ -371,7 +368,7 @@ func (m *MockTenancyInterface) EXPECT() *MockTenancyInterfaceMockRecorder { // TenancyTenantsList mocks base method. func (m *MockTenancyInterface) TenancyTenantsList(params *tenancy.TenancyTenantsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...tenancy.ClientOption) (*tenancy.TenancyTenantsListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -382,9 +379,9 @@ func (m *MockTenancyInterface) TenancyTenantsList(params *tenancy.TenancyTenants } // TenancyTenantsList indicates an expected call of TenancyTenantsList. -func (mr *MockTenancyInterfaceMockRecorder) TenancyTenantsList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockTenancyInterfaceMockRecorder) TenancyTenantsList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TenancyTenantsList", reflect.TypeOf((*MockTenancyInterface)(nil).TenancyTenantsList), varargs...) } @@ -392,7 +389,6 @@ func (mr *MockTenancyInterfaceMockRecorder) TenancyTenantsList(params, authInfo type MockExtrasInterface struct { ctrl *gomock.Controller recorder *MockExtrasInterfaceMockRecorder - isgomock struct{} } // MockExtrasInterfaceMockRecorder is the mock recorder for MockExtrasInterface. @@ -415,7 +411,7 @@ func (m *MockExtrasInterface) EXPECT() *MockExtrasInterfaceMockRecorder { // ExtrasCustomFieldsList mocks base method. func (m *MockExtrasInterface) ExtrasCustomFieldsList(params *extras.ExtrasCustomFieldsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...extras.ClientOption) (*extras.ExtrasCustomFieldsListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -426,17 +422,36 @@ func (m *MockExtrasInterface) ExtrasCustomFieldsList(params *extras.ExtrasCustom } // ExtrasCustomFieldsList indicates an expected call of ExtrasCustomFieldsList. -func (mr *MockExtrasInterfaceMockRecorder) ExtrasCustomFieldsList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockExtrasInterfaceMockRecorder) ExtrasCustomFieldsList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtrasCustomFieldsList", reflect.TypeOf((*MockExtrasInterface)(nil).ExtrasCustomFieldsList), varargs...) } +// ExtrasTagsList mocks base method. +func (m *MockExtrasInterface) ExtrasTagsList(params *extras.ExtrasTagsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...extras.ClientOption) (*extras.ExtrasTagsListOK, error) { + m.ctrl.T.Helper() + varargs := []interface{}{params, authInfo} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ExtrasTagsList", varargs...) + ret0, _ := ret[0].(*extras.ExtrasTagsListOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtrasTagsList indicates an expected call of ExtrasTagsList. +func (mr *MockExtrasInterfaceMockRecorder) ExtrasTagsList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{params, authInfo}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtrasTagsList", reflect.TypeOf((*MockExtrasInterface)(nil).ExtrasTagsList), varargs...) +} + // MockDcimInterface is a mock of DcimInterface interface. type MockDcimInterface struct { ctrl *gomock.Controller recorder *MockDcimInterfaceMockRecorder - isgomock struct{} } // MockDcimInterfaceMockRecorder is the mock recorder for MockDcimInterface. @@ -459,7 +474,7 @@ func (m *MockDcimInterface) EXPECT() *MockDcimInterfaceMockRecorder { // DcimSitesList mocks base method. func (m *MockDcimInterface) DcimSitesList(params *dcim.DcimSitesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...dcim.ClientOption) (*dcim.DcimSitesListOK, error) { m.ctrl.T.Helper() - varargs := []any{params, authInfo} + varargs := []interface{}{params, authInfo} for _, a := range opts { varargs = append(varargs, a) } @@ -470,8 +485,8 @@ func (m *MockDcimInterface) DcimSitesList(params *dcim.DcimSitesListParams, auth } // DcimSitesList indicates an expected call of DcimSitesList. -func (mr *MockDcimInterfaceMockRecorder) DcimSitesList(params, authInfo any, opts ...any) *gomock.Call { +func (mr *MockDcimInterfaceMockRecorder) DcimSitesList(params, authInfo interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{params, authInfo}, opts...) + varargs := append([]interface{}{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DcimSitesList", reflect.TypeOf((*MockDcimInterface)(nil).DcimSitesList), varargs...) } diff --git a/pkg/netbox/api/tags_test.go b/pkg/netbox/api/tags_test.go new file mode 100644 index 00000000..5c62bd16 --- /dev/null +++ b/pkg/netbox/api/tags_test.go @@ -0,0 +1,120 @@ +package api + +import ( + "errors" + "testing" + + "github.com/netbox-community/go-netbox/v3/netbox/client/extras" + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + "github.com/netbox-community/netbox-operator/gen/mock_interfaces" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestTags_GetTagDetailsByName(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "myTag" + tagSlug := "mytag" + tagId := int64(1) + + tagListRequestInput := extras.NewExtrasTagsListParams().WithName(&tagName) + tagListOutput := &extras.ExtrasTagsListOK{ + Payload: &extras.ExtrasTagsListOKBody{ + Results: []*netboxModels.Tag{ + { + ID: tagId, + Name: &tagName, + Slug: &tagSlug, + }, + }, + }, + } + + mockExtras.EXPECT().ExtrasTagsList(tagListRequestInput, nil).Return(tagListOutput, nil) + netboxClient := &NetboxClient{Extras: mockExtras} + + actual, err := netboxClient.GetTagDetails(tagName, "") + assert.NoError(t, err) + assert.Equal(t, tagName, actual.Name) + assert.Equal(t, tagId, actual.Id) + assert.Equal(t, tagSlug, actual.Slug) +} + +func TestTags_GetTagDetailsBySlug(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "myTag" + tagSlug := "mytag" + tagId := int64(1) + + tagListRequestInput := extras.NewExtrasTagsListParams().WithSlug(&tagSlug) + tagListOutput := &extras.ExtrasTagsListOK{ + Payload: &extras.ExtrasTagsListOKBody{ + Results: []*netboxModels.Tag{ + { + ID: tagId, + Name: &tagName, + Slug: &tagSlug, + }, + }, + }, + } + + mockExtras.EXPECT().ExtrasTagsList(tagListRequestInput, nil).Return(tagListOutput, nil) + netboxClient := &NetboxClient{Extras: mockExtras} + + actual, err := netboxClient.GetTagDetails("", tagSlug) + assert.NoError(t, err) + assert.Equal(t, tagName, actual.Name) + assert.Equal(t, tagId, actual.Id) + assert.Equal(t, tagSlug, actual.Slug) +} + +func TestTags_GetTagDetailsNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "notfound" + tagListRequestInput := extras.NewExtrasTagsListParams().WithName(&tagName) + tagListOutput := &extras.ExtrasTagsListOK{ + Payload: &extras.ExtrasTagsListOKBody{ + Results: []*netboxModels.Tag{}, + }, + } + + mockExtras.EXPECT().ExtrasTagsList(tagListRequestInput, nil).Return(tagListOutput, nil) + netboxClient := &NetboxClient{Extras: mockExtras} + + actual, err := netboxClient.GetTagDetails(tagName, "") + assert.Nil(t, actual) + assert.EqualError(t, err, "failed to fetch tag 'notfound/': not found") +} + +func TestTags_GetTagDetailsError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "error" + tagListRequestInput := extras.NewExtrasTagsListParams().WithName(&tagName) + + mockExtras.EXPECT().ExtrasTagsList(tagListRequestInput, nil).Return(nil, errors.New("some error")) + netboxClient := &NetboxClient{Extras: mockExtras} + + actual, err := netboxClient.GetTagDetails(tagName, "") + assert.Nil(t, actual) + assert.Contains(t, err.Error(), "failed to fetch Tag details") +} + +func TestTags_GetTagDetailsNoInput(t *testing.T) { + netboxClient := &NetboxClient{} + actual, err := netboxClient.GetTagDetails("", "") + assert.Nil(t, actual) + assert.Contains(t, err.Error(), "either name or slug must be provided") +} From 2a226da04414b0376c66396d6aeb10232d5d0378 Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 18:51:53 +0300 Subject: [PATCH 03/13] fix: erros --- api/v1/prefix_types.go | 1 - config/crd/bases/netbox.dev_prefixes.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/api/v1/prefix_types.go b/api/v1/prefix_types.go index fd9f6115..8bcf4c3f 100644 --- a/api/v1/prefix_types.go +++ b/api/v1/prefix_types.go @@ -47,7 +47,6 @@ type PrefixSpec struct { // Example: // - name: tag1 // - slug: tag2 - //+kubebuilder:validation:XValidation:rule="has(self.name) || has(self.slug)",message="One of the fields `name` or `slug` must be set" Tags []Tag `json:"tags,omitempty"` // The NetBox Custom Fields that should be added to the resource in NetBox. diff --git a/config/crd/bases/netbox.dev_prefixes.yaml b/config/crd/bases/netbox.dev_prefixes.yaml index 07924fd5..cbd13900 100644 --- a/config/crd/bases/netbox.dev_prefixes.yaml +++ b/config/crd/bases/netbox.dev_prefixes.yaml @@ -129,9 +129,6 @@ spec: type: string type: object type: array - x-kubernetes-validations: - - message: One of the fields `name` or `slug` must be set - rule: has(self.name) || has(self.slug) tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value From 740bf8ada430a2ae38bf9f19dea5f154fe6ae1be Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 18:54:46 +0300 Subject: [PATCH 04/13] chore: edit samples --- config/samples/netbox_v1_prefix.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/samples/netbox_v1_prefix.yaml b/config/samples/netbox_v1_prefix.yaml index 2deecff5..e30f8ac3 100644 --- a/config/samples/netbox_v1_prefix.yaml +++ b/config/samples/netbox_v1_prefix.yaml @@ -13,3 +13,6 @@ spec: comments: "your comments" preserveInNetbox: true prefix: "2.0.0.0/24" + tags: + - name: Alpga + - slug: golf From e8cca22edb9ddd958f99a1115dbe7845bf2c73ac Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 19:09:37 +0300 Subject: [PATCH 05/13] fix: nil tags --- gen/mock_interfaces/netbox_mocks.go | 2 +- internal/controller/prefix_controller.go | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/gen/mock_interfaces/netbox_mocks.go b/gen/mock_interfaces/netbox_mocks.go index d8ed0bed..dc33eca6 100644 --- a/gen/mock_interfaces/netbox_mocks.go +++ b/gen/mock_interfaces/netbox_mocks.go @@ -12,11 +12,11 @@ import ( reflect "reflect" runtime "github.com/go-openapi/runtime" - gomock "go.uber.org/mock/gomock" dcim "github.com/netbox-community/go-netbox/v3/netbox/client/dcim" extras "github.com/netbox-community/go-netbox/v3/netbox/client/extras" ipam "github.com/netbox-community/go-netbox/v3/netbox/client/ipam" tenancy "github.com/netbox-community/go-netbox/v3/netbox/client/tenancy" + gomock "go.uber.org/mock/gomock" ) // MockIpamInterface is a mock of IpamInterface interface. diff --git a/internal/controller/prefix_controller.go b/internal/controller/prefix_controller.go index 2d41cde5..b6544e70 100644 --- a/internal/controller/prefix_controller.go +++ b/internal/controller/prefix_controller.go @@ -292,6 +292,14 @@ func generateNetboxPrefixModelFromPrefixSpec(spec *netboxv1.PrefixSpec, req ctrl } } + convertedTags := make([]models.Tag, len(spec.Tags)) + for i, t := range spec.Tags { + convertedTags[i] = models.Tag{ + Name: t.Name, + Slug: t.Slug, + } + } + return &models.Prefix{ Prefix: spec.Prefix, Metadata: &models.NetboxMetadata{ @@ -300,6 +308,7 @@ func generateNetboxPrefixModelFromPrefixSpec(spec *netboxv1.PrefixSpec, req ctrl Description: req.NamespacedName.String() + " // " + spec.Description, Site: spec.Site, Tenant: spec.Tenant, + Tags: convertedTags, }, }, nil } From 0c17645bf772aa3063363576cf5ea91e7d07b65e Mon Sep 17 00:00:00 2001 From: keraban Date: Sun, 17 Aug 2025 19:13:12 +0300 Subject: [PATCH 06/13] fix: delete latest tag --- pkg/netbox/api/prefix.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/netbox/api/prefix.go b/pkg/netbox/api/prefix.go index 84bc34ae..5a2b2f62 100644 --- a/pkg/netbox/api/prefix.go +++ b/pkg/netbox/api/prefix.go @@ -67,8 +67,10 @@ func (r *NetboxClient) ReserveOrUpdatePrefix(prefix *models.Prefix) (*netboxMode desiredPrefix.Site = &siteDetails.Id } + desiredPrefix.Tags = []*netboxModels.NestedTag{} + // if the prefix has tags, fetch the details of each tag and add them to the desired prefix if prefix.Metadata != nil && len(prefix.Metadata.Tags) > 0 { - desiredPrefix.Tags = []*netboxModels.NestedTag{} + for _, tag := range prefix.Metadata.Tags { tagDetails, err := r.GetTagDetails(tag.Name, tag.Slug) if err != nil { From 20c7d03ea92308bdc2690f849da535153a7e6028 Mon Sep 17 00:00:00 2001 From: vaishutin <91093591+vaishutin@users.noreply.github.com> Date: Mon, 18 Aug 2025 22:06:23 +0000 Subject: [PATCH 07/13] chore: add samples tag --- config/samples/netbox_v1_prefix.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/samples/netbox_v1_prefix.yaml b/config/samples/netbox_v1_prefix.yaml index e30f8ac3..17e0cf10 100644 --- a/config/samples/netbox_v1_prefix.yaml +++ b/config/samples/netbox_v1_prefix.yaml @@ -16,3 +16,4 @@ spec: tags: - name: Alpga - slug: golf + - name: Bravo From a342718bf97289c8d1c0b6fbdbaba5586649a103 Mon Sep 17 00:00:00 2001 From: vaishutin <91093591+vaishutin@users.noreply.github.com> Date: Mon, 18 Aug 2025 22:31:24 +0000 Subject: [PATCH 08/13] fix: add header --- api/v1/tag_types.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/api/v1/tag_types.go b/api/v1/tag_types.go index 910243a7..c6ff5a25 100644 --- a/api/v1/tag_types.go +++ b/api/v1/tag_types.go @@ -1,3 +1,19 @@ +/* +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 type Tag struct { From b80a13d27d25962b76a8cfc355a93a26026fd64a Mon Sep 17 00:00:00 2001 From: vaishutin <91093591+vaishutin@users.noreply.github.com> Date: Mon, 18 Aug 2025 22:46:24 +0000 Subject: [PATCH 09/13] fix: add header --- pkg/netbox/api/tags_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/netbox/api/tags_test.go b/pkg/netbox/api/tags_test.go index 5c62bd16..d01ac6ac 100644 --- a/pkg/netbox/api/tags_test.go +++ b/pkg/netbox/api/tags_test.go @@ -1,3 +1,19 @@ +/* +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 ( From c2adfadd163a5322720ef4efa07cb0edfb09d986 Mon Sep 17 00:00:00 2001 From: keraban Date: Wed, 17 Sep 2025 11:16:24 +0300 Subject: [PATCH 10/13] feat: add support tags for other objects --- api/v1/ipaddress_types.go | 4 ++ api/v1/ipaddressclaim_types.go | 4 ++ api/v1/iprange_types.go | 4 ++ api/v1/iprangeclaim_types.go | 4 ++ api/v1/prefixclaim_types.go | 4 ++ api/v1/tag_types.go | 1 + api/v1/zz_generated.deepcopy.go | 25 +++++++ config/samples/netbox_v1_ipaddress.yaml | 2 + config/samples/netbox_v1_ipaddressclaim.yaml | 2 + config/samples/netbox_v1_iprange.yaml | 2 + config/samples/netbox_v1_iprangeclaim.yaml | 2 + config/samples/netbox_v1_prefix.yaml | 2 +- config/samples/netbox_v1_prefixclaim.yaml | 2 + ...x_v1_prefixclaim_parentprefixselector.yaml | 2 + ...ixclaim_parentprefixselector_bool_int.yaml | 2 + internal/controller/ipaddress_controller.go | 1 + .../controller/ipaddressclaim_controller.go | 2 + internal/controller/ipaddressclaim_helpers.go | 1 + internal/controller/iprange_controller.go | 1 + .../controller/iprangeclaim_controller.go | 1 + internal/controller/iprangeclaim_helpers.go | 1 + internal/controller/prefix_controller.go | 10 +-- internal/controller/prefixclaim_controller.go | 1 + internal/controller/prefixclaim_helpers.go | 1 + internal/controller/tag_helpers.go | 49 ++++++++++++++ internal/controller/tag_helpers_test.go | 45 +++++++++++++ pkg/netbox/api/ip_address.go | 9 +++ pkg/netbox/api/ip_range.go | 9 +++ pkg/netbox/api/prefix.go | 14 ++-- pkg/netbox/api/prefix_test.go | 57 ++++++++++++++-- pkg/netbox/api/tags.go | 13 ++++ pkg/netbox/api/tags_test.go | 67 +++++++++++++++++++ 32 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 internal/controller/tag_helpers.go create mode 100644 internal/controller/tag_helpers_test.go diff --git a/api/v1/ipaddress_types.go b/api/v1/ipaddress_types.go index 0c6a9b71..3dc09a52 100644 --- a/api/v1/ipaddress_types.go +++ b/api/v1/ipaddress_types.go @@ -36,6 +36,10 @@ type IpAddressSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the resource in NetBox. + // Each tag must define exactly one of the `name` or `slug` fields. + Tags []Tag `json:"tags,omitempty"` + // The NetBox Custom Fields that should be added to the resource in NetBox. // Note that currently only Text Type is supported (GitHub #129) // More info on NetBox Custom Fields: diff --git a/api/v1/ipaddressclaim_types.go b/api/v1/ipaddressclaim_types.go index f50933b6..a0cd7bc9 100644 --- a/api/v1/ipaddressclaim_types.go +++ b/api/v1/ipaddressclaim_types.go @@ -36,6 +36,10 @@ type IpAddressClaimSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the NetBox resource. + // Each tag must define exactly one of the `name` or `slug` fields. + Tags []Tag `json:"tags,omitempty"` + // The NetBox Custom Fields that should be added to the resource in NetBox. // Note that currently only Text Type is supported (GitHub #129) // More info on NetBox Custom Fields: diff --git a/api/v1/iprange_types.go b/api/v1/iprange_types.go index 2ad4e164..4adfc025 100644 --- a/api/v1/iprange_types.go +++ b/api/v1/iprange_types.go @@ -44,6 +44,10 @@ type IpRangeSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the resource in NetBox. + // Each tag must define exactly one of the `name` or `slug` fields. + Tags []Tag `json:"tags,omitempty"` + // The NetBox Custom Fields that should be added to the resource in NetBox. // Note that currently only Text Type is supported (GitHub #129) // More info on NetBox Custom Fields: diff --git a/api/v1/iprangeclaim_types.go b/api/v1/iprangeclaim_types.go index ad3c0946..f9d960c0 100644 --- a/api/v1/iprangeclaim_types.go +++ b/api/v1/iprangeclaim_types.go @@ -47,6 +47,10 @@ type IpRangeClaimSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the NetBox resource. + // Each tag must define exactly one of the `name` or `slug` fields. + Tags []Tag `json:"tags,omitempty"` + // The NetBox Custom Fields that should be added to the resource in NetBox. // Note that currently only Text Type is supported (GitHub #129) // More info on NetBox Custom Fields: diff --git a/api/v1/prefixclaim_types.go b/api/v1/prefixclaim_types.go index 24f14516..ef698271 100644 --- a/api/v1/prefixclaim_types.go +++ b/api/v1/prefixclaim_types.go @@ -61,6 +61,10 @@ type PrefixClaimSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" Tenant string `json:"tenant,omitempty"` + // A list of tags that will be assigned to the NetBox resource. + // Each tag must define exactly one of the `name` or `slug` fields. + Tags []Tag `json:"tags,omitempty"` + // Description that should be added to the resource in NetBox // Field is mutable, not required Description string `json:"description,omitempty"` diff --git a/api/v1/tag_types.go b/api/v1/tag_types.go index c6ff5a25..9f849ff4 100644 --- a/api/v1/tag_types.go +++ b/api/v1/tag_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1 type Tag struct { + // +kubebuilder:validation:XValidation:rule="(!has(self.name) && has(self.slug)) || (has(self.name) && !has(self.slug))",message="exactly one of name or slug must be specified" // +optional // Name of the tag Name string `json:"name,omitempty"` diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 08dea929..251e3b2d 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -114,6 +114,11 @@ func (in *IpAddressClaimList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IpAddressClaimSpec) DeepCopyInto(out *IpAddressClaimSpec) { *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) @@ -190,6 +195,11 @@ func (in *IpAddressList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IpAddressSpec) DeepCopyInto(out *IpAddressSpec) { *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) @@ -320,6 +330,11 @@ func (in *IpRangeClaimList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IpRangeClaimSpec) DeepCopyInto(out *IpRangeClaimSpec) { *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) @@ -406,6 +421,11 @@ func (in *IpRangeList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IpRangeSpec) DeepCopyInto(out *IpRangeSpec) { *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) @@ -543,6 +563,11 @@ func (in *PrefixClaimSpec) DeepCopyInto(out *PrefixClaimSpec) { (*out)[key] = val } } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]Tag, len(*in)) + copy(*out, *in) + } if in.CustomFields != nil { in, out := &in.CustomFields, &out.CustomFields *out = make(map[string]string, len(*in)) diff --git a/config/samples/netbox_v1_ipaddress.yaml b/config/samples/netbox_v1_ipaddress.yaml index 4937daee..a01f340c 100644 --- a/config/samples/netbox_v1_ipaddress.yaml +++ b/config/samples/netbox_v1_ipaddress.yaml @@ -11,4 +11,6 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: host-alpha ipAddress: "2.0.0.100/32" diff --git a/config/samples/netbox_v1_ipaddressclaim.yaml b/config/samples/netbox_v1_ipaddressclaim.yaml index 8bfdee0b..62112af7 100644 --- a/config/samples/netbox_v1_ipaddressclaim.yaml +++ b/config/samples/netbox_v1_ipaddressclaim.yaml @@ -11,4 +11,6 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: claim-alpha parentPrefix: "2.0.0.0/16" diff --git a/config/samples/netbox_v1_iprange.yaml b/config/samples/netbox_v1_iprange.yaml index 93ace170..1b837c57 100644 --- a/config/samples/netbox_v1_iprange.yaml +++ b/config/samples/netbox_v1_iprange.yaml @@ -11,5 +11,7 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: range-alpha startAddress: "2.0.0.200/32" endAddress: "2.0.0.202/32" diff --git a/config/samples/netbox_v1_iprangeclaim.yaml b/config/samples/netbox_v1_iprangeclaim.yaml index c6e477bd..7e54be4e 100644 --- a/config/samples/netbox_v1_iprangeclaim.yaml +++ b/config/samples/netbox_v1_iprangeclaim.yaml @@ -11,5 +11,7 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: range-claim-alpha parentPrefix: "2.0.0.0/16" size: 3 diff --git a/config/samples/netbox_v1_prefix.yaml b/config/samples/netbox_v1_prefix.yaml index 17e0cf10..ec799fff 100644 --- a/config/samples/netbox_v1_prefix.yaml +++ b/config/samples/netbox_v1_prefix.yaml @@ -14,6 +14,6 @@ spec: preserveInNetbox: true prefix: "2.0.0.0/24" tags: - - name: Alpga + - name: Alpha - slug: golf - name: Bravo diff --git a/config/samples/netbox_v1_prefixclaim.yaml b/config/samples/netbox_v1_prefixclaim.yaml index e8534426..72bbfb4f 100644 --- a/config/samples/netbox_v1_prefixclaim.yaml +++ b/config/samples/netbox_v1_prefixclaim.yaml @@ -12,5 +12,7 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: alpha parentPrefix: "2.0.0.0/16" prefixLength: "/28" diff --git a/config/samples/netbox_v1_prefixclaim_parentprefixselector.yaml b/config/samples/netbox_v1_prefixclaim_parentprefixselector.yaml index 34016354..4e0396d1 100644 --- a/config/samples/netbox_v1_prefixclaim_parentprefixselector.yaml +++ b/config/samples/netbox_v1_prefixclaim_parentprefixselector.yaml @@ -12,6 +12,8 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: selector-alpha prefixLength: "/31" parentPrefixSelector: tenant: "MY_TENANT" diff --git a/config/samples/netbox_v1_prefixclaim_parentprefixselector_bool_int.yaml b/config/samples/netbox_v1_prefixclaim_parentprefixselector_bool_int.yaml index 7ee833ca..2546b0fe 100644 --- a/config/samples/netbox_v1_prefixclaim_parentprefixselector_bool_int.yaml +++ b/config/samples/netbox_v1_prefixclaim_parentprefixselector_bool_int.yaml @@ -12,6 +12,8 @@ spec: description: "some description" comments: "your comments" preserveInNetbox: true + tags: + - name: selector-bool prefixLength: "/31" parentPrefixSelector: # should return a prefix in 3.0.0.0/24 with the sample data diff --git a/internal/controller/ipaddress_controller.go b/internal/controller/ipaddress_controller.go index 03f032d4..3bc6d049 100644 --- a/internal/controller/ipaddress_controller.go +++ b/internal/controller/ipaddress_controller.go @@ -287,6 +287,7 @@ func generateNetboxIpAddressModelFromIpAddressSpec(spec *netboxv1.IpAddressSpec, Custom: netboxCustomFields, Description: req.NamespacedName.String() + " // " + spec.Description, Tenant: spec.Tenant, + Tags: convertAPITagsToModelTags(spec.Tags), }, }, nil } diff --git a/internal/controller/ipaddressclaim_controller.go b/internal/controller/ipaddressclaim_controller.go index 2045efe4..8c2ba0ab 100644 --- a/internal/controller/ipaddressclaim_controller.go +++ b/internal/controller/ipaddressclaim_controller.go @@ -142,6 +142,7 @@ func (r *IpAddressClaimReconciler) Reconcile(ctx context.Context, req ctrl.Reque ParentPrefix: o.Spec.ParentPrefix, Metadata: &models.NetboxMetadata{ Tenant: o.Spec.Tenant, + Tags: convertAPITagsToModelTags(o.Spec.Tags), }, }) if err != nil { @@ -192,6 +193,7 @@ func (r *IpAddressClaimReconciler) Reconcile(ctx context.Context, req ctrl.Reque ipAddress.Spec.Comments = updatedIpAddressSpec.Comments ipAddress.Spec.Description = updatedIpAddressSpec.Description ipAddress.Spec.PreserveInNetbox = updatedIpAddressSpec.PreserveInNetbox + ipAddress.Spec.Tags = updatedIpAddressSpec.Tags err = controllerutil.SetControllerReference(o, ipAddress, r.Scheme) if err != nil { return err diff --git a/internal/controller/ipaddressclaim_helpers.go b/internal/controller/ipaddressclaim_helpers.go index 0552d9ac..4727eb25 100644 --- a/internal/controller/ipaddressclaim_helpers.go +++ b/internal/controller/ipaddressclaim_helpers.go @@ -55,6 +55,7 @@ func generateIpAddressSpec(claim *netboxv1.IpAddressClaim, ip string, logger log return netboxv1.IpAddressSpec{ IpAddress: ip, Tenant: claim.Spec.Tenant, + Tags: cloneAPITags(claim.Spec.Tags), CustomFields: customFields, Description: claim.Spec.Description, Comments: claim.Spec.Comments, diff --git a/internal/controller/iprange_controller.go b/internal/controller/iprange_controller.go index 916566da..fb8a66da 100644 --- a/internal/controller/iprange_controller.go +++ b/internal/controller/iprange_controller.go @@ -268,6 +268,7 @@ func (r *IpRangeReconciler) generateNetboxIpRangeModelFromIpRangeSpec(o *netboxv Custom: netboxCustomFields, Description: description, Tenant: o.Spec.Tenant, + Tags: convertAPITagsToModelTags(o.Spec.Tags), }, }, nil } diff --git a/internal/controller/iprangeclaim_controller.go b/internal/controller/iprangeclaim_controller.go index 5827ccdf..052e328a 100644 --- a/internal/controller/iprangeclaim_controller.go +++ b/internal/controller/iprangeclaim_controller.go @@ -290,6 +290,7 @@ func (r *IpRangeClaimReconciler) restoreOrAssignIpRangeAndSetCondition(ctx conte Size: o.Spec.Size, Metadata: &models.NetboxMetadata{ Tenant: o.Spec.Tenant, + Tags: convertAPITagsToModelTags(o.Spec.Tags), }, }, ) diff --git a/internal/controller/iprangeclaim_helpers.go b/internal/controller/iprangeclaim_helpers.go index a05e3c3f..ab3151b9 100644 --- a/internal/controller/iprangeclaim_helpers.go +++ b/internal/controller/iprangeclaim_helpers.go @@ -59,6 +59,7 @@ func generateIpRangeSpec(claim *netboxv1.IpRangeClaim, startIp string, endIp str StartAddress: startIp, EndAddress: endIp, Tenant: claim.Spec.Tenant, + Tags: cloneAPITags(claim.Spec.Tags), CustomFields: customFields, Description: claim.Spec.Description, Comments: claim.Spec.Comments, diff --git a/internal/controller/prefix_controller.go b/internal/controller/prefix_controller.go index d0ebd0c5..4ab5d4f6 100644 --- a/internal/controller/prefix_controller.go +++ b/internal/controller/prefix_controller.go @@ -292,14 +292,6 @@ func generateNetboxPrefixModelFromPrefixSpec(spec *netboxv1.PrefixSpec, req ctrl } } - convertedTags := make([]models.Tag, len(spec.Tags)) - for i, t := range spec.Tags { - convertedTags[i] = models.Tag{ - Name: t.Name, - Slug: t.Slug, - } - } - return &models.Prefix{ Prefix: spec.Prefix, Metadata: &models.NetboxMetadata{ @@ -308,7 +300,7 @@ func generateNetboxPrefixModelFromPrefixSpec(spec *netboxv1.PrefixSpec, req ctrl Description: req.NamespacedName.String() + " // " + spec.Description, Site: spec.Site, Tenant: spec.Tenant, - Tags: convertedTags, + Tags: convertAPITagsToModelTags(spec.Tags), }, }, nil } diff --git a/internal/controller/prefixclaim_controller.go b/internal/controller/prefixclaim_controller.go index b4200407..f5fb8e5e 100644 --- a/internal/controller/prefixclaim_controller.go +++ b/internal/controller/prefixclaim_controller.go @@ -318,6 +318,7 @@ func (r *PrefixClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) prefix.Spec.Description = updatedPrefixSpec.Description prefix.Spec.Comments = updatedPrefixSpec.Comments prefix.Spec.PreserveInNetbox = updatedPrefixSpec.PreserveInNetbox + prefix.Spec.Tags = updatedPrefixSpec.Tags err = controllerutil.SetControllerReference(o, prefix, r.Scheme) if err != nil { return err diff --git a/internal/controller/prefixclaim_helpers.go b/internal/controller/prefixclaim_helpers.go index e53cbfaa..67959b36 100644 --- a/internal/controller/prefixclaim_helpers.go +++ b/internal/controller/prefixclaim_helpers.go @@ -56,6 +56,7 @@ func generatePrefixSpec(claim *netboxv1.PrefixClaim, prefix string, logger logr. Prefix: prefix, Tenant: claim.Spec.Tenant, Site: claim.Spec.Site, + Tags: cloneAPITags(claim.Spec.Tags), CustomFields: customFields, Description: claim.Spec.Description, Comments: claim.Spec.Comments, diff --git a/internal/controller/tag_helpers.go b/internal/controller/tag_helpers.go new file mode 100644 index 00000000..8bab46e8 --- /dev/null +++ b/internal/controller/tag_helpers.go @@ -0,0 +1,49 @@ +/* +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 ( + netboxv1 "github.com/netbox-community/netbox-operator/api/v1" + "github.com/netbox-community/netbox-operator/pkg/netbox/models" +) + +func convertAPITagsToModelTags(tags []netboxv1.Tag) []models.Tag { + if len(tags) == 0 { + return nil + } + + converted := make([]models.Tag, len(tags)) + for i, tag := range tags { + converted[i] = models.Tag{ + Name: tag.Name, + Slug: tag.Slug, + } + } + + return converted +} + +func cloneAPITags(tags []netboxv1.Tag) []netboxv1.Tag { + if len(tags) == 0 { + return nil + } + + cloned := make([]netboxv1.Tag, len(tags)) + copy(cloned, tags) + + return cloned +} diff --git a/internal/controller/tag_helpers_test.go b/internal/controller/tag_helpers_test.go new file mode 100644 index 00000000..c374a459 --- /dev/null +++ b/internal/controller/tag_helpers_test.go @@ -0,0 +1,45 @@ +/* +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 ( + "testing" + + netboxv1 "github.com/netbox-community/netbox-operator/api/v1" + "github.com/netbox-community/netbox-operator/pkg/netbox/models" + "github.com/stretchr/testify/assert" +) + +func TestConvertAPITagsToModelTags(t *testing.T) { + apiTags := []netboxv1.Tag{{Name: "first"}, {Slug: "second"}} + modelTags := convertAPITagsToModelTags(apiTags) + + assert.Len(t, modelTags, 2) + assert.Equal(t, "first", modelTags[0].Name) + assert.Equal(t, "second", modelTags[1].Slug) +} + +func TestCloneAPITags(t *testing.T) { + original := []netboxv1.Tag{{Name: "original"}} + cloned := cloneAPITags(original) + + assert.Len(t, cloned, 1) + cloned[0].Name = "updated" + assert.Equal(t, "original", original[0].Name) + + assert.Nil(t, cloneAPITags(nil)) +} diff --git a/pkg/netbox/api/ip_address.go b/pkg/netbox/api/ip_address.go index 3d13666b..b0d98412 100644 --- a/pkg/netbox/api/ip_address.go +++ b/pkg/netbox/api/ip_address.go @@ -54,6 +54,15 @@ func (r *NetboxClient) ReserveOrUpdateIpAddress(ipAddress *models.IPAddress) (*n desiredIPAddress.Tenant = &tenantDetails.Id } + desiredIPAddress.Tags = []*netboxModels.NestedTag{} + if ipAddress.Metadata != nil { + var err error + desiredIPAddress.Tags, err = r.buildWritableTags(ipAddress.Metadata.Tags) + if err != nil { + return nil, err + } + } + // create ip address since it doesn't exist if len(responseIpAddress.Payload.Results) == 0 { return r.CreateIpAddress(desiredIPAddress) diff --git a/pkg/netbox/api/ip_range.go b/pkg/netbox/api/ip_range.go index 7d7cf9b4..8bf7cdb2 100644 --- a/pkg/netbox/api/ip_range.go +++ b/pkg/netbox/api/ip_range.go @@ -57,6 +57,15 @@ func (r *NetboxClient) ReserveOrUpdateIpRange(ipRange *models.IpRange) (*netboxM desiredIpRange.Tenant = &tenantDetails.Id } + desiredIpRange.Tags = []*netboxModels.NestedTag{} + if ipRange.Metadata != nil { + var err error + desiredIpRange.Tags, err = r.buildWritableTags(ipRange.Metadata.Tags) + if err != nil { + return nil, err + } + } + // create ip range since it doesn't exist if len(responseIpRange.Payload.Results) == 0 { return r.CreateIpRange(desiredIpRange) diff --git a/pkg/netbox/api/prefix.go b/pkg/netbox/api/prefix.go index 5a2b2f62..f7344d92 100644 --- a/pkg/netbox/api/prefix.go +++ b/pkg/netbox/api/prefix.go @@ -68,15 +68,11 @@ func (r *NetboxClient) ReserveOrUpdatePrefix(prefix *models.Prefix) (*netboxMode } desiredPrefix.Tags = []*netboxModels.NestedTag{} - // if the prefix has tags, fetch the details of each tag and add them to the desired prefix - if prefix.Metadata != nil && len(prefix.Metadata.Tags) > 0 { - - for _, tag := range prefix.Metadata.Tags { - tagDetails, err := r.GetTagDetails(tag.Name, tag.Slug) - if err != nil { - return nil, err - } - desiredPrefix.Tags = append(desiredPrefix.Tags, &netboxModels.NestedTag{ID: tagDetails.Id, Name: &tagDetails.Name, Slug: &tagDetails.Slug}) + if prefix.Metadata != nil { + var err error + desiredPrefix.Tags, err = r.buildWritableTags(prefix.Metadata.Tags) + if err != nil { + return nil, err } } diff --git a/pkg/netbox/api/prefix_test.go b/pkg/netbox/api/prefix_test.go index bfd4a838..82205721 100644 --- a/pkg/netbox/api/prefix_test.go +++ b/pkg/netbox/api/prefix_test.go @@ -20,6 +20,7 @@ import ( "testing" "github.com/netbox-community/go-netbox/v3/netbox/client/dcim" + "github.com/netbox-community/go-netbox/v3/netbox/client/extras" "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" @@ -376,12 +377,13 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { }, } - t.Run("reserve with tenant and site", func(t *testing.T) { + t.Run("reserve with tenant, site, and tags", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockIpam := mock_interfaces.NewMockIpamInterface(ctrl) mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) //prefix mock output createPrefixOutput := &ipam.IpamPrefixesCreateCreated{ @@ -400,16 +402,37 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { }, } - mockTenancy.EXPECT().TenancyTenantsList(tenantListRequestInput, nil).Return(tenantListRequestOutput, nil).AnyTimes() - mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(siteListRequestOutput, nil).AnyTimes() + tagByName := "tag-by-name" + tagBySlug := "tag-by-slug" + resolvedTagByName := &netboxModels.Tag{ID: 1, Name: &tagByName, Slug: &tagBySlug} + resolvedTagBySlug := &netboxModels.Tag{ID: 2, Name: &tagByName, Slug: &tagBySlug} + + nameRequest := extras.NewExtrasTagsListParams().WithName(&tagByName) + slugRequest := extras.NewExtrasTagsListParams().WithSlug(&tagBySlug) + + gomock.InOrder( + mockTenancy.EXPECT().TenancyTenantsList(tenantListRequestInput, nil).Return(tenantListRequestOutput, nil).AnyTimes(), + mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(siteListRequestOutput, nil).AnyTimes(), + mockExtras.EXPECT().ExtrasTagsList(nameRequest, nil).Return(&extras.ExtrasTagsListOK{Payload: &extras.ExtrasTagsListOKBody{Results: []*netboxModels.Tag{resolvedTagByName}}}, nil), + mockExtras.EXPECT().ExtrasTagsList(slugRequest, nil).Return(&extras.ExtrasTagsListOK{Payload: &extras.ExtrasTagsListOKBody{Results: []*netboxModels.Tag{resolvedTagBySlug}}}, nil), + ) + mockIpam.EXPECT().IpamPrefixesList(prefixListRequestInput, nil).Return(emptyPrefixListOutput, nil) - // use go mock Any as the input parameter contains pointers - mockIpam.EXPECT().IpamPrefixesCreate(gomock.Any(), nil).Return(createPrefixOutput, nil) + mockIpam.EXPECT().IpamPrefixesCreate(gomock.AssignableToTypeOf(&ipam.IpamPrefixesCreateParams{}), nil).DoAndReturn( + func(params *ipam.IpamPrefixesCreateParams, _ interface{}, _ ...ipam.ClientOption) (*ipam.IpamPrefixesCreateCreated, error) { + if assert.Len(t, params.Data.Tags, 2) { + assert.Equal(t, resolvedTagByName.ID, params.Data.Tags[0].ID) + assert.Equal(t, resolvedTagBySlug.ID, params.Data.Tags[1].ID) + } + return createPrefixOutput, nil + }, + ) netboxClient := &NetboxClient{ Ipam: mockIpam, Tenancy: mockTenancy, Dcim: mockDcim, + Extras: mockExtras, } prefixModel := models.Prefix{ @@ -420,6 +443,10 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { Site: site, Custom: make(map[string]string), Tenant: tenant, + Tags: []models.Tag{ + {Name: tagByName}, + {Slug: tagBySlug}, + }, }, } @@ -429,11 +456,12 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { assert.Nil(t, err) }) - t.Run("update without tenant and site", func(t *testing.T) { + t.Run("update without tenant and site updates tags", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockIpam := mock_interfaces.NewMockIpamInterface(ctrl) mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) prefixListOutput := &ipam.IpamPrefixesListOK{ Payload: &ipam.IpamPrefixesListOKBody{ @@ -461,11 +489,25 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { } mockIpam.EXPECT().IpamPrefixesList(prefixListRequestInput, nil).Return(prefixListOutput, nil) - mockIpam.EXPECT().IpamPrefixesUpdate(gomock.Any(), nil).Return(updatePrefixOutput, nil) + + updatedTagName := "updated-tag" + updatedTagSlug := "updated-tag" + nameRequest := extras.NewExtrasTagsListParams().WithName(&updatedTagName) + mockExtras.EXPECT().ExtrasTagsList(nameRequest, nil).Return(&extras.ExtrasTagsListOK{Payload: &extras.ExtrasTagsListOKBody{Results: []*netboxModels.Tag{{ID: 3, Name: &updatedTagName, Slug: &updatedTagSlug}}}}, nil) + + mockIpam.EXPECT().IpamPrefixesUpdate(gomock.AssignableToTypeOf(&ipam.IpamPrefixesUpdateParams{}), nil).DoAndReturn( + func(params *ipam.IpamPrefixesUpdateParams, _ interface{}, _ ...ipam.ClientOption) (*ipam.IpamPrefixesUpdateOK, error) { + if assert.Len(t, params.Data.Tags, 1) { + assert.Equal(t, int64(3), params.Data.Tags[0].ID) + } + return updatePrefixOutput, nil + }, + ) netboxClient := &NetboxClient{ Ipam: mockIpam, Tenancy: mockTenancy, + Extras: mockExtras, } prefixModel := models.Prefix{ @@ -473,6 +515,7 @@ func TestPrefix_ReserveOrUpdate(t *testing.T) { Metadata: &models.NetboxMetadata{ Comments: comments, Description: description, + Tags: []models.Tag{{Name: updatedTagName}}, }, } diff --git a/pkg/netbox/api/tags.go b/pkg/netbox/api/tags.go index a028980c..c333889d 100644 --- a/pkg/netbox/api/tags.go +++ b/pkg/netbox/api/tags.go @@ -18,6 +18,7 @@ package api import ( "github.com/netbox-community/go-netbox/v3/netbox/client/extras" + 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" @@ -53,3 +54,15 @@ func (r *NetboxClient) GetTagDetails(name string, slug string) (*models.Tag, err }, nil } + +func (r *NetboxClient) buildWritableTags(tags []models.Tag) ([]*netboxModels.NestedTag, error) { + nestedTags := make([]*netboxModels.NestedTag, 0, len(tags)) + for _, tag := range tags { + tagDetails, err := r.GetTagDetails(tag.Name, tag.Slug) + if err != nil { + return nil, err + } + nestedTags = append(nestedTags, &netboxModels.NestedTag{ID: tagDetails.Id, Name: &tagDetails.Name, Slug: &tagDetails.Slug}) + } + return nestedTags, nil +} diff --git a/pkg/netbox/api/tags_test.go b/pkg/netbox/api/tags_test.go index d01ac6ac..01255aa5 100644 --- a/pkg/netbox/api/tags_test.go +++ b/pkg/netbox/api/tags_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbox-community/go-netbox/v3/netbox/client/extras" netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" "github.com/netbox-community/netbox-operator/gen/mock_interfaces" + "github.com/netbox-community/netbox-operator/pkg/netbox/models" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) @@ -134,3 +135,69 @@ func TestTags_GetTagDetailsNoInput(t *testing.T) { assert.Nil(t, actual) assert.Contains(t, err.Error(), "either name or slug must be provided") } + +func TestTags_BuildWritableTags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "tag-by-name" + tagSlug := "tag-by-slug" + tagID := int64(1) + + nameRequest := extras.NewExtrasTagsListParams().WithName(&tagName) + nameResponse := &extras.ExtrasTagsListOK{ + Payload: &extras.ExtrasTagsListOKBody{ + Results: []*netboxModels.Tag{{ + ID: tagID, + Name: &tagName, + Slug: &tagSlug, + }}, + }, + } + + slugRequest := extras.NewExtrasTagsListParams().WithSlug(&tagSlug) + slugResponse := &extras.ExtrasTagsListOK{ + Payload: &extras.ExtrasTagsListOKBody{ + Results: []*netboxModels.Tag{{ + ID: tagID + 1, + Name: &tagName, + Slug: &tagSlug, + }}, + }, + } + + gomock.InOrder( + mockExtras.EXPECT().ExtrasTagsList(nameRequest, nil).Return(nameResponse, nil), + mockExtras.EXPECT().ExtrasTagsList(slugRequest, nil).Return(slugResponse, nil), + ) + + client := &NetboxClient{Extras: mockExtras} + + result, err := client.buildWritableTags([]models.Tag{{Name: tagName}, {Slug: tagSlug}}) + assert.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, tagID, result[0].ID) + assert.Equal(t, tagName, *result[0].Name) + assert.Equal(t, tagSlug, *result[0].Slug) + assert.Equal(t, tagID+1, result[1].ID) + assert.Equal(t, tagName, *result[1].Name) + assert.Equal(t, tagSlug, *result[1].Slug) +} + +func TestTags_BuildWritableTagsError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockExtras := mock_interfaces.NewMockExtrasInterface(ctrl) + + tagName := "tag-error" + nameRequest := extras.NewExtrasTagsListParams().WithName(&tagName) + + mockExtras.EXPECT().ExtrasTagsList(nameRequest, nil).Return(nil, errors.New("boom")) + + client := &NetboxClient{Extras: mockExtras} + + result, err := client.buildWritableTags([]models.Tag{{Name: tagName}}) + assert.Nil(t, result) + assert.Error(t, err) +} From fddee69eab61e1c9c2355cbbabe7709402d5c388 Mon Sep 17 00:00:00 2001 From: keraban Date: Wed, 17 Sep 2025 12:22:57 +0300 Subject: [PATCH 11/13] fix: fix --- internal/controller/tag_helpers_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/controller/tag_helpers_test.go b/internal/controller/tag_helpers_test.go index c374a459..c614b52a 100644 --- a/internal/controller/tag_helpers_test.go +++ b/internal/controller/tag_helpers_test.go @@ -20,7 +20,6 @@ import ( "testing" netboxv1 "github.com/netbox-community/netbox-operator/api/v1" - "github.com/netbox-community/netbox-operator/pkg/netbox/models" "github.com/stretchr/testify/assert" ) From ee721f3e16aaf22c29ee24c8be67434c4c6f752a Mon Sep 17 00:00:00 2001 From: keraban Date: Wed, 17 Sep 2025 14:06:14 +0300 Subject: [PATCH 12/13] fix: fix --- api/v1/tag_types.go | 2 +- .../crd/bases/netbox.dev_ipaddressclaims.yaml | 17 +++++++++++++++++ config/crd/bases/netbox.dev_ipaddresses.yaml | 17 +++++++++++++++++ config/crd/bases/netbox.dev_iprangeclaims.yaml | 17 +++++++++++++++++ config/crd/bases/netbox.dev_ipranges.yaml | 17 +++++++++++++++++ config/crd/bases/netbox.dev_prefixclaims.yaml | 17 +++++++++++++++++ config/crd/bases/netbox.dev_prefixes.yaml | 3 +++ 7 files changed, 89 insertions(+), 1 deletion(-) diff --git a/api/v1/tag_types.go b/api/v1/tag_types.go index 9f849ff4..ac63d314 100644 --- a/api/v1/tag_types.go +++ b/api/v1/tag_types.go @@ -16,8 +16,8 @@ limitations under the License. package v1 +// +kubebuilder:validation:XValidation:rule="has(self.name) != has(self.slug)",message="exactly one of name or slug must be specified" type Tag struct { - // +kubebuilder:validation:XValidation:rule="(!has(self.name) && has(self.slug)) || (has(self.name) && !has(self.slug))",message="exactly one of name or slug must be specified" // +optional // Name of the tag Name string `json:"name,omitempty"` diff --git a/config/crd/bases/netbox.dev_ipaddressclaims.yaml b/config/crd/bases/netbox.dev_ipaddressclaims.yaml index 225db11e..5cbcbfdb 100644 --- a/config/crd/bases/netbox.dev_ipaddressclaims.yaml +++ b/config/crd/bases/netbox.dev_ipaddressclaims.yaml @@ -105,6 +105,23 @@ spec: recreated in Kubernetes) Field is mutable, not required type: boolean + tags: + description: |- + A list of tags that will be assigned to the NetBox resource. + Each tag must define exactly one of the `name` or `slug` fields. + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) + type: array tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/config/crd/bases/netbox.dev_ipaddresses.yaml b/config/crd/bases/netbox.dev_ipaddresses.yaml index 80e60752..026dcab8 100644 --- a/config/crd/bases/netbox.dev_ipaddresses.yaml +++ b/config/crd/bases/netbox.dev_ipaddresses.yaml @@ -104,6 +104,23 @@ spec: recreated in Kubernetes) Field is mutable, not required type: boolean + tags: + description: |- + A list of tags that will be assigned to the resource in NetBox. + Each tag must define exactly one of the `name` or `slug` fields. + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) + type: array tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/config/crd/bases/netbox.dev_iprangeclaims.yaml b/config/crd/bases/netbox.dev_iprangeclaims.yaml index 248fa1cb..5696e7fe 100644 --- a/config/crd/bases/netbox.dev_iprangeclaims.yaml +++ b/config/crd/bases/netbox.dev_iprangeclaims.yaml @@ -118,6 +118,23 @@ spec: x-kubernetes-validations: - message: Field 'size' is immutable rule: self == oldSelf + tags: + description: |- + A list of tags that will be assigned to the NetBox resource. + Each tag must define exactly one of the `name` or `slug` fields. + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) + type: array tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/config/crd/bases/netbox.dev_ipranges.yaml b/config/crd/bases/netbox.dev_ipranges.yaml index c716bb15..f0510884 100644 --- a/config/crd/bases/netbox.dev_ipranges.yaml +++ b/config/crd/bases/netbox.dev_ipranges.yaml @@ -117,6 +117,23 @@ spec: x-kubernetes-validations: - message: Field 'startAddress' is immutable rule: self == oldSelf + tags: + description: |- + A list of tags that will be assigned to the resource in NetBox. + Each tag must define exactly one of the `name` or `slug` fields. + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) + type: array tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/config/crd/bases/netbox.dev_prefixclaims.yaml b/config/crd/bases/netbox.dev_prefixclaims.yaml index bf812179..c96e81c7 100644 --- a/config/crd/bases/netbox.dev_prefixclaims.yaml +++ b/config/crd/bases/netbox.dev_prefixclaims.yaml @@ -140,6 +140,23 @@ spec: x-kubernetes-validations: - message: Field 'site' is immutable rule: self == oldSelf + tags: + description: |- + A list of tags that will be assigned to the NetBox resource. + Each tag must define exactly one of the `name` or `slug` fields. + items: + properties: + name: + description: Name of the tag + type: string + slug: + description: Slug of the tag + type: string + type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) + type: array tenant: description: |- The NetBox Tenant to be assigned to this resource in NetBox. Use the `name` value instead of the `slug` value diff --git a/config/crd/bases/netbox.dev_prefixes.yaml b/config/crd/bases/netbox.dev_prefixes.yaml index cbd13900..e31b8e87 100644 --- a/config/crd/bases/netbox.dev_prefixes.yaml +++ b/config/crd/bases/netbox.dev_prefixes.yaml @@ -128,6 +128,9 @@ spec: description: Slug of the tag type: string type: object + x-kubernetes-validations: + - message: exactly one of name or slug must be specified + rule: has(self.name) != has(self.slug) type: array tenant: description: |- From 12fb706c0672eddccb0cb1583dbc99b4f5c425b7 Mon Sep 17 00:00:00 2001 From: keraban Date: Wed, 17 Sep 2025 14:35:43 +0300 Subject: [PATCH 13/13] fix: fix --- internal/controller/netbox_testdata_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/controller/netbox_testdata_test.go b/internal/controller/netbox_testdata_test.go index 0bed0e22..b8448ba0 100644 --- a/internal/controller/netbox_testdata_test.go +++ b/internal/controller/netbox_testdata_test.go @@ -248,6 +248,7 @@ var expectedIpToUpdate = &netboxModels.WritableIPAddress{ }, Description: nsn + description + warningComment, Status: "active", + Tags: []*netboxModels.NestedTag{}, Tenant: &tenantId} var expectedIpToUpdateWithHash = &netboxModels.WritableIPAddress{ @@ -259,6 +260,7 @@ var expectedIpToUpdateWithHash = &netboxModels.WritableIPAddress{ }, Description: nsn + description + warningComment, Status: "active", + Tags: []*netboxModels.NestedTag{}, Tenant: &tenantId} var ExpectedIpAddressUpdateParams = ipam.NewIpamIPAddressesUpdateParams().WithDefaults().