diff --git a/api/v1/prefix_types.go b/api/v1/prefix_types.go index d9d78b6..c70ba71 100644 --- a/api/v1/prefix_types.go +++ b/api/v1/prefix_types.go @@ -24,6 +24,7 @@ import ( // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. // PrefixSpec defines the desired state of Prefix +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.site) || has(self.site)", message="Site is required once set" type PrefixSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file @@ -33,6 +34,7 @@ type PrefixSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'prefix' is immutable" Prefix string `json:"prefix"` + //+kubebuilder:validation:XValidation:rule="self == oldSelf || self != ''",message="Field 'site' is required once set" Site string `json:"site,omitempty"` //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" diff --git a/api/v1/prefixclaim_types.go b/api/v1/prefixclaim_types.go index 0b9be4e..46ee04a 100644 --- a/api/v1/prefixclaim_types.go +++ b/api/v1/prefixclaim_types.go @@ -24,6 +24,7 @@ import ( // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. // PrefixClaimSpec defines the desired state of PrefixClaim +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.site) || has(self.site)", message="Site is required once set" type PrefixClaimSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file @@ -38,6 +39,7 @@ type PrefixClaimSpec struct { //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'prefixLength' is immutable" PrefixLength string `json:"prefixLength"` + //+kubebuilder:validation:XValidation:rule="self == oldSelf || self != ''",message="Field 'site' is required once set" Site string `json:"site,omitempty"` //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="Field 'tenant' is immutable" diff --git a/config/crd/bases/netbox.dev_prefixclaims.yaml b/config/crd/bases/netbox.dev_prefixclaims.yaml index a0a6902..b9cb7cd 100644 --- a/config/crd/bases/netbox.dev_prefixclaims.yaml +++ b/config/crd/bases/netbox.dev_prefixclaims.yaml @@ -78,6 +78,9 @@ spec: type: boolean site: type: string + x-kubernetes-validations: + - message: Field 'site' is required once set + rule: self == oldSelf || self != '' tenant: type: string x-kubernetes-validations: @@ -87,6 +90,9 @@ spec: - parentPrefix - prefixLength type: object + x-kubernetes-validations: + - message: Site is required once set + rule: '!has(oldSelf.site) || has(self.site)' status: description: PrefixClaimStatus defines the observed state of PrefixClaim properties: diff --git a/config/crd/bases/netbox.dev_prefixes.yaml b/config/crd/bases/netbox.dev_prefixes.yaml index 4417a2f..0a3db56 100644 --- a/config/crd/bases/netbox.dev_prefixes.yaml +++ b/config/crd/bases/netbox.dev_prefixes.yaml @@ -75,6 +75,9 @@ spec: type: boolean site: type: string + x-kubernetes-validations: + - message: Field 'site' is required once set + rule: self == oldSelf || self != '' tenant: type: string x-kubernetes-validations: @@ -83,6 +86,9 @@ spec: required: - prefix type: object + x-kubernetes-validations: + - message: Site is required once set + rule: '!has(oldSelf.site) || has(self.site)' status: description: PrefixStatus defines the observed state of Prefix properties: diff --git a/config/samples/netbox_v1_prefix.yaml b/config/samples/netbox_v1_prefix.yaml index 3a70aaa..be6e3fa 100644 --- a/config/samples/netbox_v1_prefix.yaml +++ b/config/samples/netbox_v1_prefix.yaml @@ -7,7 +7,7 @@ metadata: name: prefix-sample spec: tenant: "Dunder-Mifflin, Inc." - site: "DataCenter" + site: "DM-Akron" description: "some description" comments: "your comments" preserveInNetbox: true diff --git a/config/samples/netbox_v1_prefixclaim.yaml b/config/samples/netbox_v1_prefixclaim.yaml index 7bf2dd4..b23712a 100644 --- a/config/samples/netbox_v1_prefixclaim.yaml +++ b/config/samples/netbox_v1_prefixclaim.yaml @@ -7,7 +7,7 @@ metadata: name: prefixclaim-sample spec: tenant: "Dunder-Mifflin, Inc." - site: "DataCenter" + site: "DM-Akron" description: "some description" comments: "your comments" preserveInNetbox: true diff --git a/gen/mock_interfaces/netbox_mocks.go b/gen/mock_interfaces/netbox_mocks.go index c0bb38b..640c2a9 100644 --- a/gen/mock_interfaces/netbox_mocks.go +++ b/gen/mock_interfaces/netbox_mocks.go @@ -13,6 +13,7 @@ import ( reflect "reflect" runtime "github.com/go-openapi/runtime" + 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" @@ -23,6 +24,7 @@ import ( type MockIpamInterface struct { ctrl *gomock.Controller recorder *MockIpamInterfaceMockRecorder + isgomock struct{} } // MockIpamInterfaceMockRecorder is the mock recorder for MockIpamInterface. @@ -246,6 +248,7 @@ func (mr *MockIpamInterfaceMockRecorder) IpamPrefixesUpdate(params, authInfo any type MockTenancyInterface struct { ctrl *gomock.Controller recorder *MockTenancyInterfaceMockRecorder + isgomock struct{} } // MockTenancyInterfaceMockRecorder is the mock recorder for MockTenancyInterface. @@ -289,6 +292,7 @@ func (mr *MockTenancyInterfaceMockRecorder) TenancyTenantsList(params, authInfo type MockExtrasInterface struct { ctrl *gomock.Controller recorder *MockExtrasInterfaceMockRecorder + isgomock struct{} } // MockExtrasInterfaceMockRecorder is the mock recorder for MockExtrasInterface. @@ -327,3 +331,47 @@ func (mr *MockExtrasInterfaceMockRecorder) ExtrasCustomFieldsList(params, authIn varargs := append([]any{params, authInfo}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtrasCustomFieldsList", reflect.TypeOf((*MockExtrasInterface)(nil).ExtrasCustomFieldsList), 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. +type MockDcimInterfaceMockRecorder struct { + mock *MockDcimInterface +} + +// NewMockDcimInterface creates a new mock instance. +func NewMockDcimInterface(ctrl *gomock.Controller) *MockDcimInterface { + mock := &MockDcimInterface{ctrl: ctrl} + mock.recorder = &MockDcimInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDcimInterface) EXPECT() *MockDcimInterfaceMockRecorder { + return m.recorder +} + +// 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} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DcimSitesList", varargs...) + ret0, _ := ret[0].(*dcim.DcimSitesListOK) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DcimSitesList indicates an expected call of DcimSitesList. +func (mr *MockDcimInterfaceMockRecorder) DcimSitesList(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, "DcimSitesList", reflect.TypeOf((*MockDcimInterface)(nil).DcimSitesList), varargs...) +} diff --git a/internal/controller/prefixclaim_controller.go b/internal/controller/prefixclaim_controller.go index 9d4dd98..5743f2a 100644 --- a/internal/controller/prefixclaim_controller.go +++ b/internal/controller/prefixclaim_controller.go @@ -136,6 +136,7 @@ func (r *PrefixClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) PrefixLength: prefixClaim.Spec.PrefixLength, Metadata: &models.NetboxMetadata{ Tenant: prefixClaim.Spec.Tenant, + Site: prefixClaim.Spec.Site, }, }) if err != nil { diff --git a/internal/controller/prefixclaim_helpers.go b/internal/controller/prefixclaim_helpers.go index 63799c8..6108f2e 100644 --- a/internal/controller/prefixclaim_helpers.go +++ b/internal/controller/prefixclaim_helpers.go @@ -54,6 +54,7 @@ func generatePrefixSpec(claim *netboxv1.PrefixClaim, prefix string, logger logr. return netboxv1.PrefixSpec{ Prefix: prefix, Tenant: claim.Spec.Tenant, + Site: claim.Spec.Site, CustomFields: customFields, Description: claim.Spec.Description, Comments: claim.Spec.Comments, diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 8d66a36..433e5ef 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -58,6 +58,7 @@ var mockCtrl *gomock.Controller var ipamMockIpAddress *mock_interfaces.MockIpamInterface var ipamMockIpAddressClaim *mock_interfaces.MockIpamInterface var tenancyMock *mock_interfaces.MockTenancyInterface +var dcimMock *mock_interfaces.MockDcimInterface var ctx context.Context var cancel context.CancelFunc @@ -111,6 +112,7 @@ var _ = BeforeSuite(func() { ipamMockIpAddress = mock_interfaces.NewMockIpamInterface(mockCtrl) ipamMockIpAddressClaim = mock_interfaces.NewMockIpamInterface(mockCtrl) tenancyMock = mock_interfaces.NewMockTenancyInterface(mockCtrl) + dcimMock = mock_interfaces.NewMockDcimInterface(mockCtrl) k8sManager, err := ctrl.NewManager(cfg, k8sManagerOptions) Expect(k8sManager.GetConfig()).NotTo(BeNil()) @@ -123,6 +125,7 @@ var _ = BeforeSuite(func() { NetboxClient: &api.NetboxClient{ Ipam: ipamMockIpAddress, Tenancy: tenancyMock, + Dcim: dcimMock, }, OperatorNamespace: OperatorNamespace, RestConfig: k8sManager.GetConfig(), @@ -136,6 +139,7 @@ var _ = BeforeSuite(func() { NetboxClient: &api.NetboxClient{ Ipam: ipamMockIpAddressClaim, Tenancy: tenancyMock, + Dcim: dcimMock, }, OperatorNamespace: OperatorNamespace, RestConfig: k8sManager.GetConfig(), diff --git a/pkg/netbox/api/client.go b/pkg/netbox/api/client.go index e2b7109..d08c093 100644 --- a/pkg/netbox/api/client.go +++ b/pkg/netbox/api/client.go @@ -40,6 +40,7 @@ type NetboxClient struct { Ipam interfaces.IpamInterface Tenancy interfaces.TenancyInterface Extras interfaces.ExtrasInterface + Dcim interfaces.DcimInterface } // Checks that the Netbox host is properly configured for the operator to function. @@ -108,6 +109,7 @@ func GetNetboxClient() (*NetboxClient, error) { Ipam: auxNetboxClient.Ipam, Tenancy: auxNetboxClient.Tenancy, Extras: auxNetboxClient.Extras, + Dcim: auxNetboxClient.Dcim, } return netboxClient, nil diff --git a/pkg/netbox/api/ip_address_claim_test.go b/pkg/netbox/api/ip_address_claim_test.go index 7d1ddd8..1acc725 100644 --- a/pkg/netbox/api/ip_address_claim_test.go +++ b/pkg/netbox/api/ip_address_claim_test.go @@ -404,7 +404,7 @@ func TestIPAddressClaim_GetNoAvailableIPAddressWithTenancyChecks(t *testing.T) { } // expected error - expectedErrorMsg := "failed to fetch tenant: not found" + expectedErrorMsg := "failed to fetch tenant 'non-existing-tenant': not found" // mock empty list call mockTenancy.EXPECT().TenancyTenantsList(inputTenant, nil).Return(emptyTenantList, nil).AnyTimes() diff --git a/pkg/netbox/api/prefix.go b/pkg/netbox/api/prefix.go index 46d58ba..6198aaf 100644 --- a/pkg/netbox/api/prefix.go +++ b/pkg/netbox/api/prefix.go @@ -51,6 +51,14 @@ func (r *NetboxClient) ReserveOrUpdatePrefix(prefix *models.Prefix) (*netboxMode desiredPrefix.Tenant = &tenantDetails.Id } + if prefix.Metadata.Site != "" { + siteDetails, err := r.GetSiteDetails(prefix.Metadata.Site) + if err != nil { + return nil, err + } + desiredPrefix.Site = &siteDetails.Id + } + // create prefix since it doesn't exist if len(responsePrefix.Payload.Results) == 0 { return r.CreatePrefix(desiredPrefix) diff --git a/pkg/netbox/api/prefix_claim.go b/pkg/netbox/api/prefix_claim.go index a6de143..be1d58d 100644 --- a/pkg/netbox/api/prefix_claim.go +++ b/pkg/netbox/api/prefix_claim.go @@ -87,6 +87,14 @@ func (r *NetboxClient) GetAvailablePrefixByClaim(prefixClaim *models.PrefixClaim return nil, err } + // Don't assign an prefix if the requested site doesn't exist in netbox + if prefixClaim.Metadata.Site != "" { + _, err := r.GetSiteDetails(prefixClaim.Metadata.Site) + if err != nil { + return nil, err + } + } + responseParentPrefix, err := r.GetPrefix(&models.Prefix{ Prefix: prefixClaim.ParentPrefix, Metadata: prefixClaim.Metadata, diff --git a/pkg/netbox/api/prefix_claim_test.go b/pkg/netbox/api/prefix_claim_test.go index f05c20a..354313c 100644 --- a/pkg/netbox/api/prefix_claim_test.go +++ b/pkg/netbox/api/prefix_claim_test.go @@ -21,6 +21,7 @@ import ( "strconv" "testing" + "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" @@ -150,6 +151,7 @@ func TestPrefixClaim_GetBestFitPrefixByClaim(t *testing.T) { defer ctrl.Finish() mockPrefixIpam := mock_interfaces.NewMockIpamInterface(ctrl) mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) + mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) // example of tenant tenantId := int64(2) @@ -166,6 +168,24 @@ func TestPrefixClaim_GetBestFitPrefixByClaim(t *testing.T) { }, }, } + inputTenant := tenancy.NewTenancyTenantsListParams().WithName(&tenantName) + + // example of site + siteId := int64(3) + siteName := "Site1" + siteOutputSlug := "site1" + expectedSite := &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Results: []*netboxModels.Site{ + { + ID: siteId, + Name: &siteName, + Slug: &siteOutputSlug, + }, + }, + }, + } + inputSite := dcim.NewDcimSitesListParams().WithName(&siteName) parentPrefix := "10.112.140.0/24" parentPrefixId := int64(1) @@ -188,8 +208,6 @@ func TestPrefixClaim_GetBestFitPrefixByClaim(t *testing.T) { }, } - inputTenant := tenancy.NewTenancyTenantsListParams().WithName(&tenantName) - prefixAvailableListInput := ipam.NewIpamPrefixesAvailablePrefixesListParams().WithID(parentPrefixId) prefixAvailableListOutput := &ipam.IpamPrefixesAvailablePrefixesListOK{ Payload: []*netboxModels.AvailablePrefix{ @@ -202,10 +220,12 @@ func TestPrefixClaim_GetBestFitPrefixByClaim(t *testing.T) { mockPrefixIpam.EXPECT().IpamPrefixesList(prefixListInput, nil).Return(prefixListOutput, nil).AnyTimes() mockPrefixIpam.EXPECT().IpamPrefixesAvailablePrefixesList(prefixAvailableListInput, nil).Return(prefixAvailableListOutput, nil).AnyTimes() mockTenancy.EXPECT().TenancyTenantsList(inputTenant, nil).Return(expectedTenant, nil).AnyTimes() + mockDcim.EXPECT().DcimSitesList(inputSite, nil).Return(expectedSite, nil).AnyTimes() netboxClient := &NetboxClient{ Ipam: mockPrefixIpam, Tenancy: mockTenancy, + Dcim: mockDcim, } actual, err := netboxClient.GetAvailablePrefixByClaim(&models.PrefixClaim{ @@ -213,6 +233,7 @@ func TestPrefixClaim_GetBestFitPrefixByClaim(t *testing.T) { PrefixLength: "/28", Metadata: &models.NetboxMetadata{ Tenant: tenantName, + Site: siteName, }, }) @@ -819,7 +840,7 @@ func TestPrefixClaim_GetNoAvailablePrefixesWithNonExistingTenant(t *testing.T) { inputTenant := tenancy.NewTenancyTenantsListParams().WithName(&tenantName) // expected error - expectedErrorMsg := "failed to fetch tenant: not found" + expectedErrorMsg := "failed to fetch tenant 'non-existing-tenant': not found" // empty tenant list emptyTenantList := &tenancy.TenancyTenantsListOK{ @@ -888,3 +909,141 @@ func TestPrefixClaim_GetNoAvailablePrefixesWithErrorWhenGettingTenantList(t *tes // assert nil output assert.Equal(t, prefix, (*models.Prefix)(nil)) } + +func TestPrefixClaim_GetNoAvailablePrefixesWithNonExistingSite(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) + mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) + + // tenant + tenantName := "tenant" + tenantId := int64(2) + tenantOutputSlug := "tenant1" + + inputTenant := tenancy.NewTenancyTenantsListParams().WithName(&tenantName) + expectedTenant := &tenancy.TenancyTenantsListOK{ + Payload: &tenancy.TenancyTenantsListOKBody{ + Results: []*netboxModels.Tenant{ + { + ID: tenantId, + Name: &tenantName, + Slug: &tenantOutputSlug, + }, + }, + }, + } + + // non-existing site + siteName := "non-existing-site" + inputSite := dcim.NewDcimSitesListParams().WithName(&siteName) + // empty site list + emptySiteList := &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Results: []*netboxModels.Site{}, + }, + } + + mockDcim.EXPECT().DcimSitesList(inputSite, nil).Return(emptySiteList, nil).AnyTimes() + mockTenancy.EXPECT().TenancyTenantsList(inputTenant, nil).Return(expectedTenant, nil).AnyTimes() + + // expected error + expectedErrorMsg := "failed to fetch site 'non-existing-site': not found" + + parentPrefix := "10.112.140.0/24" + + netboxClient := &NetboxClient{ + Dcim: mockDcim, + Tenancy: mockTenancy, + } + + prefix, err := netboxClient.GetAvailablePrefixByClaim(&models.PrefixClaim{ + ParentPrefix: parentPrefix, + PrefixLength: "/28.", + Metadata: &models.NetboxMetadata{ + Tenant: tenantName, + Site: siteName, + }, + }) + + assert.EqualErrorf(t, err, expectedErrorMsg, "Error should be: %v, got: %v", expectedErrorMsg, err) + assert.Equal(t, prefix, (*models.Prefix)(nil)) +} + +func TestPrefixClaim_GetAvailablePrefixIfNoSiteInSpec(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) + mockPrefixIpam := mock_interfaces.NewMockIpamInterface(ctrl) + + // tenant + tenantName := "tenant" + tenantId := int64(2) + tenantOutputSlug := "tenant1" + + inputTenant := tenancy.NewTenancyTenantsListParams().WithName(&tenantName) + expectedTenant := &tenancy.TenancyTenantsListOK{ + Payload: &tenancy.TenancyTenantsListOKBody{ + Results: []*netboxModels.Tenant{ + { + ID: tenantId, + Name: &tenantName, + Slug: &tenantOutputSlug, + }, + }, + }, + } + + parentPrefix := "10.112.140.0/24" + parentPrefixId := int64(1) + prefix := "10.112.140.14/28" + prefixListInput := ipam. + NewIpamPrefixesListParams(). + WithPrefix(&parentPrefix) + + prefixFamily := int64(IPv4Family) + prefixFamilyLabel := netboxModels.PrefixFamilyLabelIPV4 + prefixListOutput := &ipam.IpamPrefixesListOK{ + Payload: &ipam.IpamPrefixesListOKBody{ + Results: []*netboxModels.Prefix{ + { + ID: parentPrefixId, + Prefix: &parentPrefix, + Family: &netboxModels.PrefixFamily{Label: &prefixFamilyLabel, Value: &prefixFamily}, + }, + }, + }, + } + + prefixAvailableListInput := ipam.NewIpamPrefixesAvailablePrefixesListParams().WithID(parentPrefixId) + prefixAvailableListOutput := &ipam.IpamPrefixesAvailablePrefixesListOK{ + Payload: []*netboxModels.AvailablePrefix{ + { + Prefix: prefix, + }, + }, + } + + mockPrefixIpam.EXPECT().IpamPrefixesList(prefixListInput, nil).Return(prefixListOutput, nil).AnyTimes() + mockPrefixIpam.EXPECT().IpamPrefixesAvailablePrefixesList(prefixAvailableListInput, nil).Return(prefixAvailableListOutput, nil).AnyTimes() + mockTenancy.EXPECT().TenancyTenantsList(inputTenant, nil).Return(expectedTenant, nil).AnyTimes() + + netboxClient := &NetboxClient{ + Ipam: mockPrefixIpam, + Tenancy: mockTenancy, + } + + actual, err := netboxClient.GetAvailablePrefixByClaim(&models.PrefixClaim{ + ParentPrefix: parentPrefix, + PrefixLength: "/28", + Metadata: &models.NetboxMetadata{ + Tenant: tenantName, + Site: "", + }, + }) + + assert.Nil(t, err) + assert.Equal(t, prefix, actual.Prefix) +} diff --git a/pkg/netbox/api/prefix_test.go b/pkg/netbox/api/prefix_test.go index 3ae2ca9..0ab45ec 100644 --- a/pkg/netbox/api/prefix_test.go +++ b/pkg/netbox/api/prefix_test.go @@ -19,6 +19,7 @@ package api import ( "testing" + "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" @@ -316,3 +317,182 @@ func TestPrefix_DeletePrefix(t *testing.T) { err := netboxClient.DeletePrefix(prefixId) assert.Nil(t, err) } + +func TestPrefix_ReserveOrUpdate(t *testing.T) { + // tenant mock input + tenant := "Tenant1" + tenantListRequestInput := tenancy.NewTenancyTenantsListParams().WithName(&tenant) + + // tenant mock output + tenantOutputId := int64(1) + tenantOutputSlug := "tenant1" + tenantListRequestOutput := &tenancy.TenancyTenantsListOK{ + Payload: &tenancy.TenancyTenantsListOKBody{ + Results: []*netboxModels.Tenant{ + { + ID: tenantOutputId, + Name: &tenant, + Slug: &tenantOutputSlug, + }, + }, + }, + } + + // site mock input + site := "Site3" + siteListRequestInput := dcim.NewDcimSitesListParams().WithName(&site) + + // site mock output + siteOutputId := int64(3) + siteOutputSlug := "site3" + siteListRequestOutput := &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Results: []*netboxModels.Site{ + { + ID: siteOutputId, + Name: &site, + Slug: &siteOutputSlug, + }, + }, + }, + } + + // prefix mock input + prefix := "10.112.140.0/24" + prefixPtr := &prefix + prefixListRequestInput := ipam. + NewIpamPrefixesListParams(). + WithPrefix(prefixPtr) + + //prefix mock output + prefixId := int64(4) + comments := "blabla" + description := "very useful prefix" + + emptyPrefixListOutput := &ipam.IpamPrefixesListOK{ + Payload: &ipam.IpamPrefixesListOKBody{ + Results: []*netboxModels.Prefix{}, + }, + } + + t.Run("reserve with tenant and site", 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) + + //prefix mock input + prefixToCreate := &netboxModels.WritablePrefix{ + Comments: comments + warningComment, + Description: description + warningComment, + CustomFields: make(map[string]string), + Prefix: prefixPtr, + Site: &siteOutputId, + Tenant: &tenantOutputId, + Status: "active", + } + + createPrefixInput := ipam. + NewIpamPrefixesCreateParams(). + WithDefaults(). + WithData(prefixToCreate) + + //prefix mock output + createPrefixOutput := &ipam.IpamPrefixesCreateCreated{ + Payload: &netboxModels.Prefix{ + ID: int64(1), + Comments: comments + warningComment, + Description: description + warningComment, + Display: prefix, + Prefix: prefixPtr, + Site: &netboxModels.NestedSite{ + ID: siteOutputId, + }, + Tenant: &netboxModels.NestedTenant{ + ID: tenantOutputId, + }, + }, + } + + mockTenancy.EXPECT().TenancyTenantsList(tenantListRequestInput, nil).Return(tenantListRequestOutput, nil).AnyTimes() + mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(siteListRequestOutput, nil).AnyTimes() + mockIpam.EXPECT().IpamPrefixesList(prefixListRequestInput, nil).Return(emptyPrefixListOutput, nil) + mockIpam.EXPECT().IpamPrefixesCreate(createPrefixInput, nil).Return(createPrefixOutput, nil) + + netboxClient := &NetboxClient{ + Ipam: mockIpam, + Tenancy: mockTenancy, + Dcim: mockDcim, + } + + prefixModel := models.Prefix{ + Prefix: prefix, + Metadata: &models.NetboxMetadata{ + Comments: comments, + Description: description, + Site: site, + Custom: make(map[string]string), + Tenant: tenant, + }, + } + + _, err := netboxClient.ReserveOrUpdatePrefix(&prefixModel) + // skip assertion on retured values as the payload of IpamPrefixesCreate() is returened + // without manipulation by the code + assert.Nil(t, err) + }) + + t.Run("update without tenant and site", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockIpam := mock_interfaces.NewMockIpamInterface(ctrl) + mockTenancy := mock_interfaces.NewMockTenancyInterface(ctrl) + + prefixListOutput := &ipam.IpamPrefixesListOK{ + Payload: &ipam.IpamPrefixesListOKBody{ + Results: []*netboxModels.Prefix{ + { + ID: prefixId, + Comments: comments + warningComment, + Description: description + warningComment, + Display: prefix, + Prefix: prefixPtr, + }, + }, + }, + } + + //prefix mock output + updatePrefixOutput := &ipam.IpamPrefixesUpdateOK{ + Payload: &netboxModels.Prefix{ + ID: prefixId, + Comments: comments + warningComment, + Description: description + warningComment, + Display: prefix, + Prefix: prefixPtr, + }, + } + + mockIpam.EXPECT().IpamPrefixesList(prefixListRequestInput, nil).Return(prefixListOutput, nil) + mockIpam.EXPECT().IpamPrefixesUpdate(gomock.Any(), nil).Return(updatePrefixOutput, nil) + + netboxClient := &NetboxClient{ + Ipam: mockIpam, + Tenancy: mockTenancy, + } + + prefixModel := models.Prefix{ + Prefix: prefix, + Metadata: &models.NetboxMetadata{ + Comments: comments, + Description: description, + }, + } + + _, err := netboxClient.ReserveOrUpdatePrefix(&prefixModel) + // skip assertion on retured values as the payload of IpamPrefixesUpdate() is returened + // without manipulation by the code + assert.Nil(t, err) + }) +} diff --git a/pkg/netbox/api/site.go b/pkg/netbox/api/site.go new file mode 100644 index 0000000..8df4b45 --- /dev/null +++ b/pkg/netbox/api/site.go @@ -0,0 +1,41 @@ +/* +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/dcim" + + "github.com/netbox-community/netbox-operator/pkg/netbox/models" + "github.com/netbox-community/netbox-operator/pkg/netbox/utils" +) + +func (r *NetboxClient) GetSiteDetails(name string) (*models.Site, error) { + request := dcim.NewDcimSitesListParams().WithName(&name) + response, err := r.Dcim.DcimSitesList(request, nil) + if err != nil { + return nil, utils.NetboxError("failed to fetch Site details", err) + } + if len(response.Payload.Results) == 0 { + return nil, utils.NetboxNotFoundError("site '" + name + "'") + } + + return &models.Site{ + Id: response.Payload.Results[0].ID, + Slug: *response.Payload.Results[0].Slug, + Name: *response.Payload.Results[0].Name, + }, nil +} diff --git a/pkg/netbox/api/site_test.go b/pkg/netbox/api/site_test.go new file mode 100644 index 0000000..1168fd3 --- /dev/null +++ b/pkg/netbox/api/site_test.go @@ -0,0 +1,104 @@ +/* +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 ( + "errors" + "testing" + + "github.com/netbox-community/netbox-operator/gen/mock_interfaces" + + "github.com/netbox-community/go-netbox/v3/netbox/client/dcim" + netboxModels "github.com/netbox-community/go-netbox/v3/netbox/models" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestSite_GetSiteDetails(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) + + site := "mySite" + + siteListRequestInput := dcim.NewDcimSitesListParams().WithName(&site) + + siteOutputId := int64(1) + siteOutputSlug := "mysite" + siteListOutput := &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Results: []*netboxModels.Site{ + { + ID: siteOutputId, + Name: &site, + Slug: &siteOutputSlug, + }, + }, + }, + } + + mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(siteListOutput, nil) + netboxClient := &NetboxClient{Dcim: mockDcim} + + actual, err := netboxClient.GetSiteDetails(site) + assert.NoError(t, err) + assert.Equal(t, site, actual.Name) + assert.Equal(t, siteOutputId, actual.Id) + assert.Equal(t, siteOutputSlug, actual.Slug) +} + +func TestSite_GetEmptyResult(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) + + site := "mySite" + + siteListRequestInput := dcim.NewDcimSitesListParams().WithName(&site) + + emptyListSiteOutput := &dcim.DcimSitesListOK{ + Payload: &dcim.DcimSitesListOKBody{ + Results: []*netboxModels.Site{}, + }, + } + + mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(emptyListSiteOutput, nil) + netboxClient := &NetboxClient{Dcim: mockDcim} + + actual, err := netboxClient.GetSiteDetails(site) + assert.Nil(t, actual) + assert.EqualError(t, err, "failed to fetch site 'mySite': not found") +} + +func TestSite_GetError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDcim := mock_interfaces.NewMockDcimInterface(ctrl) + + site := "mySite" + + siteListRequestInput := dcim.NewDcimSitesListParams().WithName(&site) + + expectedErr := "error geting sites list" + + mockDcim.EXPECT().DcimSitesList(siteListRequestInput, nil).Return(nil, errors.New(expectedErr)) + netboxClient := &NetboxClient{Dcim: mockDcim} + + actual, err := netboxClient.GetSiteDetails(site) + assert.Nil(t, actual) + assert.EqualError(t, err, "failed to fetch Site details: "+expectedErr) +} diff --git a/pkg/netbox/api/tenancy.go b/pkg/netbox/api/tenancy.go index 12b816a..8740f97 100644 --- a/pkg/netbox/api/tenancy.go +++ b/pkg/netbox/api/tenancy.go @@ -30,7 +30,7 @@ func (r *NetboxClient) GetTenantDetails(name string) (*models.Tenant, error) { return nil, utils.NetboxError("failed to fetch Tenant details", err) } if len(response.Payload.Results) == 0 { - return nil, utils.NetboxNotFoundError("tenant") + return nil, utils.NetboxNotFoundError("tenant '" + name + "'") } return &models.Tenant{ diff --git a/pkg/netbox/api/tenancy_test.go b/pkg/netbox/api/tenancy_test.go index d8238db..5a501cc 100644 --- a/pkg/netbox/api/tenancy_test.go +++ b/pkg/netbox/api/tenancy_test.go @@ -80,5 +80,5 @@ func TestTenancy_GetWrongTenantDetails(t *testing.T) { actual, err := netboxClient.GetTenantDetails(wrongTenant) assert.Nil(t, actual) - assert.EqualError(t, err, "failed to fetch tenant: not found") + assert.EqualError(t, err, "failed to fetch tenant 'wrongTenant': not found") } diff --git a/pkg/netbox/interfaces/netbox.go b/pkg/netbox/interfaces/netbox.go index c752ea8..6171de0 100644 --- a/pkg/netbox/interfaces/netbox.go +++ b/pkg/netbox/interfaces/netbox.go @@ -18,6 +18,7 @@ package interfaces import ( "github.com/go-openapi/runtime" + "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" @@ -44,3 +45,7 @@ type TenancyInterface interface { type ExtrasInterface interface { ExtrasCustomFieldsList(params *extras.ExtrasCustomFieldsListParams, authInfo runtime.ClientAuthInfoWriter, opts ...extras.ClientOption) (*extras.ExtrasCustomFieldsListOK, error) } + +type DcimInterface interface { + DcimSitesList(params *dcim.DcimSitesListParams, authInfo runtime.ClientAuthInfoWriter, opts ...dcim.ClientOption) (*dcim.DcimSitesListOK, error) +} diff --git a/pkg/netbox/models/ipam.go b/pkg/netbox/models/ipam.go index 8e44e61..19bfb12 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 Site struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` +} + type NetboxMetadata struct { Comments string `json:"comments,omitempty"` Custom map[string]string `json:"customFields,omitempty"`