From 0cbdf16cb8881b4769f2bc780a8bbc1efb2b190b Mon Sep 17 00:00:00 2001 From: Mara Sophie Grosch Date: Fri, 4 Mar 2022 15:08:23 +0100 Subject: [PATCH] IPAMv1: WIP --- pkg/apis/ipam/v1/address_e2e_mock_test.go | 189 ++++++++++++++++++ pkg/apis/ipam/v1/address_e2e_test.go | 144 ++++++++++++++ pkg/apis/ipam/v1/address_genclient.go | 67 +++++++ pkg/apis/ipam/v1/address_test.go | 56 ++++++ pkg/apis/ipam/v1/address_types.go | 28 +++ pkg/apis/ipam/v1/common_test.go | 46 +++++ pkg/apis/ipam/v1/common_types.go | 52 +++++ pkg/apis/ipam/v1/e2e_mock_test.go | 30 +++ pkg/apis/ipam/v1/e2e_prod_test.go | 121 ++++++++++++ pkg/apis/ipam/v1/e2e_test.go | 31 +++ pkg/apis/ipam/v1/prefix_e2e_mock_test.go | 188 ++++++++++++++++++ pkg/apis/ipam/v1/prefix_e2e_test.go | 208 ++++++++++++++++++++ pkg/apis/ipam/v1/prefix_genclient.go | 79 ++++++++ pkg/apis/ipam/v1/prefix_options.go | 23 +++ pkg/apis/ipam/v1/prefix_test.go | 51 +++++ pkg/apis/ipam/v1/prefix_types.go | 33 ++++ pkg/apis/ipam/v1/suite_test.go | 13 ++ pkg/apis/ipam/v1/xxgenerated_object_test.go | 32 +++ 18 files changed, 1391 insertions(+) create mode 100644 pkg/apis/ipam/v1/address_e2e_mock_test.go create mode 100644 pkg/apis/ipam/v1/address_e2e_test.go create mode 100644 pkg/apis/ipam/v1/address_genclient.go create mode 100644 pkg/apis/ipam/v1/address_test.go create mode 100644 pkg/apis/ipam/v1/address_types.go create mode 100644 pkg/apis/ipam/v1/common_test.go create mode 100644 pkg/apis/ipam/v1/common_types.go create mode 100644 pkg/apis/ipam/v1/e2e_mock_test.go create mode 100644 pkg/apis/ipam/v1/e2e_prod_test.go create mode 100644 pkg/apis/ipam/v1/e2e_test.go create mode 100644 pkg/apis/ipam/v1/prefix_e2e_mock_test.go create mode 100644 pkg/apis/ipam/v1/prefix_e2e_test.go create mode 100644 pkg/apis/ipam/v1/prefix_genclient.go create mode 100644 pkg/apis/ipam/v1/prefix_options.go create mode 100644 pkg/apis/ipam/v1/prefix_test.go create mode 100644 pkg/apis/ipam/v1/prefix_types.go create mode 100644 pkg/apis/ipam/v1/suite_test.go create mode 100644 pkg/apis/ipam/v1/xxgenerated_object_test.go diff --git a/pkg/apis/ipam/v1/address_e2e_mock_test.go b/pkg/apis/ipam/v1/address_e2e_mock_test.go new file mode 100644 index 00000000..734ef803 --- /dev/null +++ b/pkg/apis/ipam/v1/address_e2e_mock_test.go @@ -0,0 +1,189 @@ +//go:build !integration +// +build !integration + +package v1_test + +import ( + "fmt" + "math" + "net" + + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + testutils "go.anx.io/go-anxcloud/pkg/utils/test" + + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/ghttp" +) + +const ( + mockAddressIdentifier = "address-foobarbaz4223691337" +) + +func mockBasicAddressResponseBody(p ipamv1.Prefix, desc string, ip string) map[string]interface{} { + ret := map[string]interface{}{ + "identifier": testutils.TestResourceName(), + "name": ip, + "description_customer": desc, + "version": p.Version, + "role_text": "Default", + "status": "Active", + "prefix": p.Identifier, + } + + if len(p.VLANs) == 1 { + ret["vlan"] = p.VLANs[0].Identifier + } + + return ret +} + +func mockAddressResponseBody(p ipamv1.Prefix, desc string, ip net.IP) map[string]interface{} { + status := "Pending" + + if mockPrefixGetDeleting { + status = "Marked for deletion" + } else if mockPrefixGetActive { + status = "Active" + } + + ret := mockBasicAddressResponseBody(p, desc, ip.String()) + ret["identifier"] = mockAddressIdentifier + ret["status"] = status + + return ret +} + +func prepareAddressCreate(p ipamv1.Prefix, desc string, ip net.IP) { + exp := map[string]interface{}{ + "name": ip, + "description_customer": desc, + "version": p.Version, + "prefix": p.Identifier, + } + + if len(p.VLANs) == 1 { + exp["vlan"] = p.VLANs[0].Identifier + } + + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("POST", "/api/ipam/v1/address.json"), + VerifyJSONRepresenting(exp), + RespondWithJSONEncoded(200, mockAddressResponseBody(p, desc, ip)), + )) +} + +func prepareAddressGet(p ipamv1.Prefix, desc string, ip net.IP) { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("GET", "/api/ipam/v1/address.json/"+mockAddressIdentifier), + RespondWithJSONEncoded(200, mockAddressResponseBody(p, desc, ip)), + )) +} + +func prepareAddressList(p ipamv1.Prefix, shouldEmpty bool, desc string, ip net.IP) { + baseIP, _, err := net.ParseCIDR(p.Name) + Expect(err).NotTo(HaveOccurred(), "expected a parsable prefix") + + var mockAddresses []map[string]interface{} + + if !shouldEmpty { + if p.Version == ipamv1.FamilyIPv6 { + // this would run out of memory very badly + panic("never ever try to have an IPv6 prefix that is _not empty_") + } + + if p.Netmask < 24 { + panic("e2e mocks only supports netmask >= 24") + } + + numAddresses := int(math.Exp2(float64(32 - p.Netmask))) + mockAddresses = make([]map[string]interface{}, numAddresses) + + for i := range mockAddresses { + ip := make(net.IP, len(baseIP)) + copy(ip, baseIP) + ip[len(ip)-1] += byte(i) + mockAddresses[i] = mockBasicAddressResponseBody(p, testutils.TestResourceName(), ip.String()) + } + + ipIdx := ip[len(ip)-1] - baseIP[len(baseIP)-1] + mockAddresses[ipIdx] = mockAddressResponseBody(p, desc, ip) + + } else { + mockAddresses = make([]map[string]interface{}, 0, 4) + mockAddresses = append(mockAddresses, mockBasicAddressResponseBody(p, "Network address", baseIP.String())) + + gatewayIP := make(net.IP, len(baseIP)) + copy(gatewayIP, baseIP) + gatewayIP[len(gatewayIP)-1]++ + mockAddresses = append(mockAddresses, mockBasicAddressResponseBody(p, "Gateway", gatewayIP.String())) + + mockAddresses = append(mockAddresses, mockAddressResponseBody(p, desc, ip)) + + if p.Version == ipamv1.FamilyIPv4 { + broadcastIP := make(net.IP, len(baseIP)) + copy(broadcastIP, baseIP) + broadcastIP[len(broadcastIP)-1] += 7 // let's statically calc for a /29, good enough for the mock + mockAddresses = append(mockAddresses, mockBasicAddressResponseBody(p, "Broadcast", broadcastIP.String())) + } + } + + pageCount := len(mockAddresses) / 10 + if pageCount*10 < len(mockAddresses) { + pageCount++ + } + + Expect(pageCount).To(BeNumerically(">=", 1)) + + expectedQuery := fmt.Sprintf( + "prefix=%v&version=%v&private=%v", + p.Identifier, p.Version, p.Type == ipamv1.AddressSpacePrivate, + ) + + for i := 0; i <= pageCount; i++ { + pageQuery := fmt.Sprintf("%v&page=%v&limit=10", expectedQuery, i+1) + + var data []map[string]interface{} + + if i < pageCount { + firstIdx := 10 * i + count := 10 + + if firstIdx+count > len(mockAddresses) { + count = len(mockAddresses) - firstIdx + } + + data = mockAddresses[firstIdx:count] + } else { + data = make([]map[string]interface{}, 0) + } + + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("GET", "/api/ipam/v1/address/filtered.json", pageQuery), + RespondWithJSONEncoded(200, map[string]interface{}{ + "page": i + 1, + "total_pages": pageCount, + "total_items": len(mockAddresses), + "limit": 10, + "data": data, + }), + )) + } +} + +func prepareAddressUpdate(p ipamv1.Prefix, desc string, ip net.IP) { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("PUT", "/api/ipam/v1/address.json/"+mockAddressIdentifier), + VerifyJSONRepresenting(map[string]interface{}{ + "identifier": mockAddressIdentifier, + "description_customer": desc, + }), + RespondWithJSONEncoded(200, mockAddressResponseBody(p, desc, ip)), + )) +} + +func prepareAddressDelete(p ipamv1.Prefix, desc string, ip net.IP) { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("DELETE", "/api/ipam/v1/address.json/"+mockAddressIdentifier), + RespondWithJSONEncoded(200, mockAddressResponseBody(p, desc, ip)), + )) +} diff --git a/pkg/apis/ipam/v1/address_e2e_test.go b/pkg/apis/ipam/v1/address_e2e_test.go new file mode 100644 index 00000000..ea2e37f5 --- /dev/null +++ b/pkg/apis/ipam/v1/address_e2e_test.go @@ -0,0 +1,144 @@ +package v1_test + +import ( + "context" + "net" + + "go.anx.io/go-anxcloud/pkg/api" + "go.anx.io/go-anxcloud/pkg/api/types" + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + testutils "go.anx.io/go-anxcloud/pkg/utils/test" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func testAddress(c *api.API, shouldBeEmpty bool, p *ipamv1.Prefix) { + var apiClient api.API + var prefix ipamv1.Prefix + + BeforeEach(func() { + apiClient = *c + prefix = *p + }) + + testForIP := func(ip *net.IP) { + var address ipamv1.Address + + BeforeAll(func() { + address = ipamv1.Address{ + Name: ip.String(), + DescriptionCustomer: testutils.TestResourceName(), + Version: prefix.Version, + Prefix: prefix, + } + + if len(prefix.VLANs) == 1 { + address.VLAN = prefix.VLANs[0] + } + }) + + if shouldBeEmpty { + It("creates the test address", func() { + prepareAddressCreate(prefix, address.DescriptionCustomer, *ip) + + err := apiClient.Create(context.TODO(), &address) + Expect(err).NotTo(HaveOccurred()) + }) + } + + It("finds the test address", func() { + prepareAddressList(prefix, shouldBeEmpty, address.DescriptionCustomer, *ip) + + var oc types.ObjectChannel + err := apiClient.List( + context.TODO(), + &ipamv1.Address{ + Prefix: prefix, + Version: prefix.Version, + Type: prefix.Type, + }, + api.ObjectChannel(&oc), + ) + Expect(err).NotTo(HaveOccurred()) + + addressCount := 0 + for retriever := range oc { + var addr ipamv1.Address + err := retriever(&addr) + Expect(err).NotTo(HaveOccurred()) + + addressCount++ + + if net.ParseIP(addr.Name).Equal(*ip) { + address.Identifier = addr.Identifier + } + } + + // network address, gateway and our test IP + expectedIPs := 3 + + if prefix.Version == ipamv1.FamilyIPv4 { + // for IPv4 we additionally get the broadcast address + expectedIPs++ + + if !shouldBeEmpty { + // we test with /29 prefixes, so there should be 8 addresses when not created empty + expectedIPs = 8 + } + } + + Expect(addressCount).To(Equal(expectedIPs)) + }) + + It("retrieves the test address", func() { + prepareAddressGet(prefix, address.DescriptionCustomer, *ip) + + err := apiClient.Get(context.TODO(), &address) + Expect(err).NotTo(HaveOccurred()) + + Expect(address.Name).To(Equal(ip.String())) + }) + + It("updates the test address description", func() { + address.DescriptionCustomer += " - Updated!" + prepareAddressUpdate(prefix, address.DescriptionCustomer, *ip) + + err := apiClient.Update(context.TODO(), &ipamv1.Address{ + Identifier: address.Identifier, + DescriptionCustomer: address.DescriptionCustomer, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("retrieves the test address with the new description", func() { + prepareAddressGet(prefix, address.DescriptionCustomer, *ip) + + err := apiClient.Get(context.TODO(), &address) + Expect(err).NotTo(HaveOccurred()) + + Expect(address.Name).To(Equal(ip.String())) + }) + + It("deletes the test address", func() { + prepareAddressDelete(prefix, address.DescriptionCustomer, *ip) + + err := apiClient.Destroy(context.TODO(), &address) + Expect(err).NotTo(HaveOccurred()) + }) + } + + Context("fixed address", Ordered, func() { + ip := new(net.IP) + + BeforeAll(func() { + i, _, err := net.ParseCIDR(prefix.Name) + Expect(err).NotTo(HaveOccurred(), "expected parsable prefix") + + i[len(i)-1] += 3 + *ip = i + }) + + testForIP(ip) + }) +} diff --git a/pkg/apis/ipam/v1/address_genclient.go b/pkg/apis/ipam/v1/address_genclient.go new file mode 100644 index 00000000..e0dd9af0 --- /dev/null +++ b/pkg/apis/ipam/v1/address_genclient.go @@ -0,0 +1,67 @@ +package v1 + +import ( + "context" + "net/url" + + "go.anx.io/go-anxcloud/pkg/api/types" + "go.anx.io/go-anxcloud/pkg/utils/object/filter" +) + +func (a *Address) EndpointURL(ctx context.Context) (*url.URL, error) { + op, err := types.OperationFromContext(ctx) + if err != nil { + return nil, err + } + + if op == types.OperationList { + fh, err := filter.NewHelper(a) + if err != nil { + return nil, err + } + + query := fh.BuildQuery() + + if v, ok, err := fh.Get("type"); ok && err == nil { + delete(query, "type") + + if v.(AddressSpace) == AddressSpacePublic { + query.Set("private", "false") + } else { + query.Set("private", "true") + } + } else if err != nil { + return nil, err + } + + u, _ := url.Parse("/api/ipam/v1/address/filtered.json") + u.RawQuery = query.Encode() + return u, nil + } + + return url.Parse("/api/ipam/v1/address.json") +} + +func (a *Address) FilterAPIRequestBody(ctx context.Context) (interface{}, error) { + op, err := types.OperationFromContext(ctx) + if err != nil { + return nil, err + } + + // Creating a Prefix is done with a single location and VLAN only, despite the API returning arrays. + if op == types.OperationCreate { + data := struct { + Address + Prefix string `json:"prefix"` + VLAN string `json:"vlan,omitempty"` + }{ + Address: *a, + Prefix: a.Prefix.Identifier, + VLAN: a.VLAN.Identifier, + } + + return data, nil + } + + return a, nil +} diff --git a/pkg/apis/ipam/v1/address_test.go b/pkg/apis/ipam/v1/address_test.go new file mode 100644 index 00000000..3bd3d768 --- /dev/null +++ b/pkg/apis/ipam/v1/address_test.go @@ -0,0 +1,56 @@ +package v1 + +import ( + "context" + "net/url" + + "go.anx.io/go-anxcloud/pkg/api/types" + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + vlanv1 "go.anx.io/go-anxcloud/pkg/apis/vlan/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Address filtered listing", func() { + DescribeTable("correctly sets query parameters", + func(a Address, queryData ...string) { + if len(queryData)%2 != 0 { + panic("invalid test case! queryData is a list of key-value-pairs, meaning it needs a even number of entries") + } + + ctx := types.ContextWithOperation(context.TODO(), types.OperationList) + u, err := a.EndpointURL(ctx) + Expect(err).NotTo(HaveOccurred()) + + expQuery := make(url.Values) + + for i, k := range queryData { + if i%2 == 1 { + continue + } + + v := queryData[i+1] + + expQuery.Set(k, v) + } + + Expect(u.Query()).To(BeEquivalentTo(expQuery)) + }, + Entry("no filter set", Address{}), + + Entry("version filter set for IPv4", Address{Version: FamilyIPv4}, "version", "4"), + Entry("version filter set for IPv6", Address{Version: FamilyIPv6}, "version", "6"), + + Entry("type filter set for public", Address{Type: AddressSpacePublic}, "private", "false"), + Entry("type filter set for private", Address{Type: AddressSpacePrivate}, "private", "true"), + + Entry("status filter set for StatusActive", Address{Status: StatusActive}, "status", "Active"), + + Entry("location filter set for a test Location", Address{Location: corev1.Location{Identifier: "foo"}}, "location", "foo"), + + Entry("prefix filter set for a test prefix", Address{Prefix: Prefix{Identifier: "foo"}}, "prefix", "foo"), + + Entry("vlan filter set for a test vlan", Address{VLAN: vlanv1.VLAN{Identifier: "foo"}}, "vlan", "foo"), + ) +}) diff --git a/pkg/apis/ipam/v1/address_types.go b/pkg/apis/ipam/v1/address_types.go new file mode 100644 index 00000000..a4dc2409 --- /dev/null +++ b/pkg/apis/ipam/v1/address_types.go @@ -0,0 +1,28 @@ +package v1 + +import ( + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + vlanv1 "go.anx.io/go-anxcloud/pkg/apis/vlan/v1" +) + +// anxcloud:object + +type Address struct { + Identifier string `json:"identifier,omitempty" anxcloud:"identifier"` + Name string `json:"name,omitempty"` + DescriptionCustomer string `json:"description_customer"` + Version Family `json:"version,omitempty" anxcloud:"filterable"` + RoleText string `json:"role_text,omitempty"` + Status Status `json:"status,omitempty" anxcloud:"filterable"` + + VLAN vlanv1.VLAN `json:"-" anxcloud:"filterable,vlan"` + + // Prefix of the address, only for filtering and creating and not returned by the API + Prefix Prefix `json:"-" anxcloud:"filterable,prefix"` + + // Location of this address, only for filtering and not returned by the API + Location corev1.Location `json:"-" anxcloud:"filterable,location"` + + // Type of this address (public or private), only for filtering and not returned by the API + Type AddressSpace `json:"-" anxcloud:"filterable,type"` +} diff --git a/pkg/apis/ipam/v1/common_test.go b/pkg/apis/ipam/v1/common_test.go new file mode 100644 index 00000000..aa20e75a --- /dev/null +++ b/pkg/apis/ipam/v1/common_test.go @@ -0,0 +1,46 @@ +package v1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AddressSpace JSON encoding", func() { + var t AddressSpace + + Context("initialized to public", func() { + BeforeEach(func() { + t = AddressSpacePublic + }) + + It("encodes correctly", func() { + d, err := t.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(d).To(Equal([]byte(`0`))) + }) + + It("decodes correctly", func() { + err := t.UnmarshalJSON([]byte(`1`)) + Expect(err).NotTo(HaveOccurred()) + Expect(t).To(Equal(AddressSpacePrivate)) + }) + }) + + Context("initialized to private", func() { + BeforeEach(func() { + t = AddressSpacePrivate + }) + + It("encodes correctly", func() { + d, err := t.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(d).To(Equal([]byte(`1`))) + }) + + It("decodes correctly", func() { + err := t.UnmarshalJSON([]byte(`0`)) + Expect(err).NotTo(HaveOccurred()) + Expect(t).To(Equal(AddressSpacePublic)) + }) + }) +}) diff --git a/pkg/apis/ipam/v1/common_types.go b/pkg/apis/ipam/v1/common_types.go new file mode 100644 index 00000000..80fea101 --- /dev/null +++ b/pkg/apis/ipam/v1/common_types.go @@ -0,0 +1,52 @@ +package v1 + +// Family defines an address family/IP version. +type Family int + +const ( + // FamilyIPv4 denotes a Prefix as being an IPv4 prefix. + FamilyIPv4 Family = 4 + + // FamilyIPv6 denotes a Prefix as being an IPv6 prefix. + FamilyIPv6 Family = 6 +) + +// Status describes the status of an object, if it is being created, ready to be used, ... +type Status string + +const ( + // StatusActive marks an address or prefix as being allocated and ready to be used. + StatusActive Status = "Active" + + // StatusPending marks an address or prefix as being worked on and not yet ready to be used. + StatusPending Status = "Pending" + + // StatusFailed marks an address or prefix as being failed, you have to contact support. + StatusFailed Status = "Failed" + + // StatusMarkedForDeletion marks an address or prefix as being in the process of being deleted. + StatusMarkedForDeletion Status = "Marked for deletion" +) + +// AddressSpace defines if an address or prefix is from public/internet routable or private address space. +type AddressSpace string + +const ( + // TypePublic specifies an address or prefix as being from public/internet-routable address space. + AddressSpacePublic AddressSpace = "0" + + // TypePrivate specifies an address or prefix as being from private (RFC1918) address space. + AddressSpacePrivate AddressSpace = "1" +) + +// MarshalJSON encodes Type into JSON and is required because the API expects a number, but we use a string +// to differentiate between AddressSpacePublic and no AddressSpace set for filtering. +func (t AddressSpace) MarshalJSON() ([]byte, error) { + return []byte(t), nil +} + +// UnmarshalJSON decodes the given JSON value into the AddressSpace it is called on. See MarshalJSON why it is needed. +func (t *AddressSpace) UnmarshalJSON(data []byte) error { + *t = AddressSpace(string(data)) + return nil +} diff --git a/pkg/apis/ipam/v1/e2e_mock_test.go b/pkg/apis/ipam/v1/e2e_mock_test.go new file mode 100644 index 00000000..1d6c3bd4 --- /dev/null +++ b/pkg/apis/ipam/v1/e2e_mock_test.go @@ -0,0 +1,30 @@ +//go:build !integration +// +build !integration + +package v1_test + +import ( + "go.anx.io/go-anxcloud/pkg/api" + "go.anx.io/go-anxcloud/pkg/client" + + . "github.com/onsi/gomega/ghttp" +) + +var ( + mockServer *Server +) + +func e2eApiClient() (api.API, error) { + if mockServer == nil { + mockServer = NewServer() + } + + vlanIdentifier = "randomVLANIdentifier" + + return api.NewAPI( + api.WithClientOptions( + client.BaseURL(mockServer.URL()), + client.IgnoreMissingToken(), + ), + ) +} diff --git a/pkg/apis/ipam/v1/e2e_prod_test.go b/pkg/apis/ipam/v1/e2e_prod_test.go new file mode 100644 index 00000000..2fbc5fa4 --- /dev/null +++ b/pkg/apis/ipam/v1/e2e_prod_test.go @@ -0,0 +1,121 @@ +//go:build integration +// +build integration + +package v1_test + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "go.anx.io/go-anxcloud/pkg/api" + "go.anx.io/go-anxcloud/pkg/api/types" + "go.anx.io/go-anxcloud/pkg/client" + testutils "go.anx.io/go-anxcloud/pkg/utils/test" + + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + vlanv1 "go.anx.io/go-anxcloud/pkg/apis/vlan/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var ( + errPrefixNotReadyForDelete = errors.New("Prefix not yet ready to be deleted") +) + +func e2eApiClient() (api.API, error) { + SetDefaultEventuallyTimeout(15 * time.Minute) + SetDefaultEventuallyPollingInterval(15 * time.Second) + + return api.NewAPI(api.WithClientOptions(client.AuthFromEnv(false))) +} + +var _ = SynchronizedBeforeSuite(func() []byte { + c, err := e2eApiClient() + Expect(err).NotTo(HaveOccurred()) + + vlan := vlanv1.VLAN{ + DescriptionCustomer: fmt.Sprintf("%s - go-anxcloud IPAM E2E", testutils.TestResourceName()), + Locations: []corev1.Location{ + {Identifier: locationIdentifier}, + }, + } + + err = c.Create(context.TODO(), &vlan) + Expect(err).NotTo(HaveOccurred()) + + return []byte(vlan.Identifier) +}, func(identifier []byte) { + vlanIdentifier = string(identifier) +}) + +var _ = SynchronizedAfterSuite(func() {}, func() { + c, err := e2eApiClient() + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + err := cleanupVLAN(c) + g.Expect(err).NotTo(HaveOccurred()) + }, 30*time.Minute, 15*time.Second).Should(Succeed()) +}) + +func cleanupVLAN(c api.API) error { + ctx, cancel := context.WithCancel(context.TODO()) + defer cancel() + + var oc types.ObjectChannel + if err := c.List(ctx, &ipamv1.Prefix{}, api.ObjectChannel(&oc), api.FullObjects(true)); err != nil { + cancel() + return fmt.Errorf("listing prefixes failed: %w", err) + } + + for retriever := range oc { + var p ipamv1.Prefix + if err := retriever(&p); err != nil { + cancel() + return fmt.Errorf("retrieving prefix failed: %w", err) + } + + // we cannot filter Prefixes by VLAN (ENGSUP-5702), so we list all Prefixes and do + // client-side filtering here. + if len(p.VLANs) != 1 || p.VLANs[0].Identifier != vlanIdentifier { + continue + } + + if err := cleanupPrefix(c, p); err != nil { + cancel() + return fmt.Errorf("prefix cleanup failed: %w", err) + } + } + + // delete the VLAN + return c.Destroy(context.TODO(), &vlanv1.VLAN{Identifier: vlanIdentifier}) +} + +func cleanupPrefix(c api.API, p ipamv1.Prefix) error { + if p.Status == ipamv1.StatusMarkedForDeletion { + return nil + } else if p.Status != ipamv1.StatusActive { + return fmt.Errorf("error deleting prefix %q: %w", p.Identifier, errPrefixNotReadyForDelete) + } + + return c.Destroy(context.TODO(), &p) +} + +// below are the functions for setting up the mock, empty for the prod E2E version +func preparePrefixCreate(string, *bool, ipamv1.Family, ipamv1.AddressSpace) {} +func preparePrefixGet(string, ipamv1.Family, ipamv1.AddressSpace) {} +func preparePrefixList(string, ipamv1.Family, ipamv1.AddressSpace) {} +func preparePrefixUpdate(string, ipamv1.Family, ipamv1.AddressSpace) {} +func preparePrefixDelete() {} +func preparePrefixEventuallyActive(string, ipamv1.Family, ipamv1.AddressSpace) {} +func preparePrefixEventuallyDeleted(string, ipamv1.Family, ipamv1.AddressSpace) {} +func prepareAddressCreate(ipamv1.Prefix, string, net.IP) {} +func prepareAddressGet(ipamv1.Prefix, string, net.IP) {} +func prepareAddressList(ipamv1.Prefix, bool, string, net.IP) {} +func prepareAddressUpdate(ipamv1.Prefix, string, net.IP) {} +func prepareAddressDelete(ipamv1.Prefix, string, net.IP) {} diff --git a/pkg/apis/ipam/v1/e2e_test.go b/pkg/apis/ipam/v1/e2e_test.go new file mode 100644 index 00000000..364f4469 --- /dev/null +++ b/pkg/apis/ipam/v1/e2e_test.go @@ -0,0 +1,31 @@ +package v1_test + +import ( + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + + . "github.com/onsi/ginkgo/v2" +) + +const ( + locationIdentifier = "52b5f6b2fd3a4a7eaaedf1a7c019e9ea" +) + +var vlanIdentifier string + +var _ = Describe("IPAM E2E tests", func() { + testPrefix("working with private IPv4", ipamv1.FamilyIPv4, ipamv1.AddressSpacePrivate) + testPrefix("working with private IPv6", ipamv1.FamilyIPv6, ipamv1.AddressSpacePrivate) + testPrefix("working with public IPv4", ipamv1.FamilyIPv4, ipamv1.AddressSpacePublic) + testPrefix("working with public IPv6", ipamv1.FamilyIPv6, ipamv1.AddressSpacePublic) +}) + +func netmaskForFamily(fam ipamv1.Family) int { + switch fam { + case ipamv1.FamilyIPv4: + return 29 + case ipamv1.FamilyIPv6: + return 64 + } + + panic("Invalid family") +} diff --git a/pkg/apis/ipam/v1/prefix_e2e_mock_test.go b/pkg/apis/ipam/v1/prefix_e2e_mock_test.go new file mode 100644 index 00000000..ea9c8013 --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_e2e_mock_test.go @@ -0,0 +1,188 @@ +//go:build !integration +// +build !integration + +package v1_test + +import ( + "fmt" + "net/http" + + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/ghttp" +) + +const ( + mockPrefixIdentifier = "prefix-foobarbaz4223691337" +) + +var ( + // Engine takes some time to assign a name, emulate with this + mockPrefixGetAssigned = false + + // Engine takes some time to have a new VLAN active, emulate with this + mockPrefixGetActive = false + + // After destroying, the Engine needs some time to actually delete it - emulate with this + mockPrefixGetDeleting = false + + // This is set to true to return a 404 response when retrieving our test VLAN + mockPrefixGetDeleted = false +) + +func mockPrefixResponseBody(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) map[string]interface{} { + netmask := netmaskForFamily(fam) + status := "Pending" + prefix := "newPrefix422369" + + if mockPrefixGetAssigned { + switch fam { + case ipamv1.FamilyIPv4: + prefix = "10.244.0.0" + case ipamv1.FamilyIPv6: + prefix = "2001:db8:1337::" + } + } + + name := fmt.Sprintf("%v/%v", prefix, netmask) + + if mockPrefixGetDeleting { + status = "Marked for deletion" + } else if mockPrefixGetActive { + status = "Active" + } + + return map[string]interface{}{ + "name": name, + "description_customer": desc, + "identifier": mockPrefixIdentifier, + "netmask": netmask, + "version": fam, + "type": space, + "status": status, + "router_redundancy": false, + "locations": []map[string]interface{}{ + { + "identifier": locationIdentifier, + "name": "ANX04", + }, + }, + "vlans": []map[string]interface{}{ + { + "identifier": vlanIdentifier, + }, + }, + } +} + +func preparePrefixCreate(desc string, createEmpty *bool, fam ipamv1.Family, space ipamv1.AddressSpace) { + expected := map[string]interface{}{ + "description_customer": desc, + "netmask": netmaskForFamily(fam), + "version": fam, + "type": space, + "location": locationIdentifier, + "vlan": vlanIdentifier, + "router_redundancy": false, + } + + if createEmpty != nil { + expected["create_empty"] = *createEmpty + } + + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("POST", "/api/ipam/v1/prefix.json"), + VerifyJSONRepresenting(expected), + RespondWithJSONEncoded(200, mockPrefixResponseBody(desc, fam, space)), + )) +} + +func preparePrefixDelete() { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("DELETE", "/api/ipam/v1/prefix.json/"+mockPrefixIdentifier), + RespondWithJSONEncoded(200, map[string]interface{}{ + "identifier": nil, + "name": nil, + "description_customer": nil, + }), + )) +} + +func preparePrefixGet(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) { + var response http.HandlerFunc + + if mockPrefixGetDeleted { + response = RespondWith(404, ``) + } else { + body := mockPrefixResponseBody(desc, fam, space) + mockPrefixGetAssigned = true // after first request it's going to be assigned + response = RespondWithJSONEncoded(200, body) + } + + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("GET", "/api/ipam/v1/prefix.json/"+mockPrefixIdentifier), + response, + )) +} + +func preparePrefixList(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) { + mockVLANs := []map[string]interface{}{ + {"identifier": "foo", "name": "10.0.0.0/8", "description_customer": "black lives matter", "role_text": "Default"}, + {"identifier": "bar", "name": "10.1.0.0/8", "description_customer": "trans rights are human rights", "role_text": "Default"}, + {"identifier": "blarz", "name": "10.2.0.0/8", "description_customer": "more good strings accepted via PR", "role_text": "Default"}, + {"identifier": "aölskdjasd", "name": "10.3.0.0/8", "description_customer": "aöäslkdjlsdkgjh.lfdknhdfg", "role_text": "Default"}, + {"identifier": "IShouldUsePwgen", "name": "fd00::/64", "description_customer": "I really should use pwgen for this.", "role_text": "Default"}, + {"identifier": "6 more to go", "name": "2001:db8::/32", "description_customer": "I need at least two pages, having our mock one on the second page to test if its iterating correctly", "role_text": "Default"}, + {"identifier": "5 more to go", "name": "2001:db8:1337/48", "description_customer": "booooooring", "role_text": "Default"}, + {"identifier": "4 more to go", "name": "192.168.0.0/24", "description_customer": "hey reviewer, are you reading this?", "role_text": "Default"}, + {"identifier": "3 more to go", "name": "192.168.1.0/24", "description_customer": "because, if you do, I hope you are less bored", "role_text": "Default"}, + {"identifier": "2 more to go", "name": "172.16.0.0/16", "description_customer": "google: how to have fun mocking things", "role_text": "Default"}, + {"identifier": "1 more to go", "name": "172.17.0.224/29", "description_customer": "This is the last random one!", "role_text": "Default"}, + mockPrefixResponseBody(desc, fam, space), + } + + pages := [][]map[string]interface{}{ + mockVLANs[0:10], + mockVLANs[10:], + } + + Expect(pages[0]).To(HaveLen(10)) + Expect(pages[1]).To(HaveLen(2)) + + for i, data := range pages { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("GET", "/api/ipam/v1/prefix/filtered.json", fmt.Sprintf("page=%v&limit=10", i+1)), + RespondWithJSONEncoded(200, map[string]interface{}{ + "page": i + 1, + "total_pages": len(pages), + "total_items": len(mockVLANs), + "limit": len(data), + "data": data, + }), + )) + } +} + +func preparePrefixEventuallyActive(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) { + preparePrefixGet(desc, fam, space) + mockPrefixGetActive = true // Active on second request +} + +func preparePrefixUpdate(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) { + mockServer.AppendHandlers(CombineHandlers( + VerifyRequest("PUT", "/api/ipam/v1/prefix.json/"+mockPrefixIdentifier), + VerifyJSONRepresenting(map[string]interface{}{ + "identifier": mockPrefixIdentifier, + "description_customer": desc, + "router_redundancy": false, + }), + RespondWithJSONEncoded(200, mockPrefixResponseBody(desc, fam, space)), + )) +} + +func preparePrefixEventuallyDeleted(desc string, fam ipamv1.Family, space ipamv1.AddressSpace) { + mockPrefixGetDeleting = true + preparePrefixGet(desc, fam, space) + mockPrefixGetDeleted = true // Active on second request +} diff --git a/pkg/apis/ipam/v1/prefix_e2e_test.go b/pkg/apis/ipam/v1/prefix_e2e_test.go new file mode 100644 index 00000000..89725ca1 --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_e2e_test.go @@ -0,0 +1,208 @@ +package v1_test + +import ( + "context" + "errors" + "net/http" + + "github.com/onsi/gomega/types" + "go.anx.io/go-anxcloud/pkg/api" + apiTypes "go.anx.io/go-anxcloud/pkg/api/types" + + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + ipamv1 "go.anx.io/go-anxcloud/pkg/apis/ipam/v1" + vlanv1 "go.anx.io/go-anxcloud/pkg/apis/vlan/v1" + testutils "go.anx.io/go-anxcloud/pkg/utils/test" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func testPrefix(test string, fam ipamv1.Family, space ipamv1.AddressSpace) { + c := new(api.API) + var apiClient api.API + + BeforeEach(func() { + a, err := e2eApiClient() + Expect(err).ToNot(HaveOccurred()) + *c = a + apiClient = a + }) + + Context(test, Ordered, func() { + // netmask to the prefix to create for testing + netmask := netmaskForFamily(fam) + + // if the prefix is expected to contain addresses + shouldBeEmpty := true + + if fam == ipamv1.FamilyIPv4 && space == ipamv1.AddressSpacePublic { + shouldBeEmpty = false + } + + prefixDescription := testutils.TestResourceName() + + matchPrefixExpectation := func() types.GomegaMatcher { + return SatisfyAll( + WithTransform(func(p ipamv1.Prefix) string { + return p.DescriptionCustomer + }, Equal(prefixDescription)), + + WithTransform(func(p ipamv1.Prefix) int { + return p.Netmask + }, Equal(netmask)), + + WithTransform(func(p ipamv1.Prefix) ipamv1.Family { + return p.Version + }, Equal(fam)), + + WithTransform(func(p ipamv1.Prefix) []string { + ret := make([]string, 0, len(p.Locations)) + for _, l := range p.Locations { + ret = append(ret, l.Identifier) + } + return ret + }, ContainElement(locationIdentifier)), + + WithTransform(func(p ipamv1.Prefix) []string { + ret := make([]string, 0, len(p.VLANs)) + for _, v := range p.VLANs { + ret = append(ret, v.Identifier) + } + return ret + }, Equal([]string{vlanIdentifier})), + ) + } + + var prefix ipamv1.Prefix + + It("creates a prefix", func() { + var createEmpty *bool = nil + + if fam == ipamv1.FamilyIPv4 { + createEmpty = &shouldBeEmpty + } + + preparePrefixCreate(prefixDescription, createEmpty, fam, space) + + prefix = ipamv1.Prefix{ + DescriptionCustomer: prefixDescription, + Version: fam, + Netmask: netmask, + Type: space, + Locations: []corev1.Location{{Identifier: locationIdentifier}}, + VLANs: []vlanv1.VLAN{{Identifier: vlanIdentifier}}, + } + + opts := make([]apiTypes.CreateOption, 0) + + if createEmpty != nil { + opts = append(opts, ipamv1.CreateEmpty(*createEmpty)) + } + + err := apiClient.Create(context.TODO(), &prefix, opts...) + Expect(err).NotTo(HaveOccurred()) + }) + + It("includes the prefix when listing all prefixes", func() { + preparePrefixList(prefixDescription, fam, space) + + ctx, cancel := context.WithCancel(context.TODO()) + defer cancel() + + var oc apiTypes.ObjectChannel + err := apiClient.List(ctx, &ipamv1.Prefix{}, api.ObjectChannel(&oc)) + Expect(err).NotTo(HaveOccurred()) + + found := false + for retriever := range oc { + var p ipamv1.Prefix + if err := retriever(&p); err != nil { + cancel() + Fail("expected retriever to succeed") + break + } + + if p.Identifier == prefix.Identifier { + found = true + cancel() + break + } + } + + Expect(found).To(BeTrue()) + }) + + It("eventually retrieves the prefix as Active", func() { + Eventually(func(g types.Gomega) { + preparePrefixEventuallyActive(prefixDescription, fam, space) + + err := apiClient.Get(context.TODO(), &prefix) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(prefix).To(matchPrefixExpectation()) + g.Expect(prefix.Status).To(Equal(ipamv1.StatusActive)) + }).Should(Succeed()) + }) + + It("updates the prefix description", func() { + prefixDescription += " - Updated!" + preparePrefixUpdate(prefixDescription, fam, space) + + err := apiClient.Update(context.TODO(), &ipamv1.Prefix{ + Identifier: prefix.Identifier, + DescriptionCustomer: prefixDescription, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("retrieves the prefix with updated description", func() { + preparePrefixGet(prefixDescription, fam, space) + + err := apiClient.Get(context.TODO(), &prefix) + Expect(err).NotTo(HaveOccurred()) + + Expect(prefix).To(matchPrefixExpectation()) + Expect(prefix.Status).To(Equal(ipamv1.StatusActive)) + }) + + testAddress(c, shouldBeEmpty, &prefix) + + // we use the same code in the last spec and in AfterAll, AfterAll being there in case any previous + // test fails + // ref https://github.com/onsi/ginkgo/issues/933 + cleanup := func() { + preparePrefixDelete() + + err := apiClient.Destroy(context.TODO(), &ipamv1.Prefix{Identifier: prefix.Identifier}) + Expect(err).NotTo(HaveOccurred()) + } + + AfterAll(func() { + if prefix.Identifier != "" { + cleanup() + } + }) + + It("deletes the test prefix", func() { + cleanup() + }) + + It("eventually sees the prefix being gone", func() { + Eventually(func(g types.Gomega) { + preparePrefixEventuallyDeleted(prefixDescription, fam, space) + + err := apiClient.Get(context.TODO(), &prefix) + g.Expect(err).To(HaveOccurred()) + + var httpError api.HTTPError + ok := errors.As(err, &httpError) + g.Expect(ok).To(BeTrue()) + + g.Expect(httpError.StatusCode()).To(Equal(http.StatusNotFound)) + }).Should(Succeed()) + + prefix.Identifier = "" + }) + }) +} diff --git a/pkg/apis/ipam/v1/prefix_genclient.go b/pkg/apis/ipam/v1/prefix_genclient.go new file mode 100644 index 00000000..3d04e6a6 --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_genclient.go @@ -0,0 +1,79 @@ +package v1 + +import ( + "context" + "fmt" + "net/url" + + "go.anx.io/go-anxcloud/pkg/api/types" + "go.anx.io/go-anxcloud/pkg/utils/object/filter" +) + +func (p *Prefix) EndpointURL(ctx context.Context) (*url.URL, error) { + op, err := types.OperationFromContext(ctx) + if err != nil { + return nil, err + } + + if op == types.OperationList { + fh, err := filter.NewHelper(p) + if err != nil { + return nil, err + } + + u, _ := url.Parse("/api/ipam/v1/prefix/filtered.json") + u.RawQuery = fh.BuildQuery().Encode() + return u, nil + } + + return url.Parse("/api/ipam/v1/prefix.json") +} + +func (p *Prefix) FilterAPIRequestBody(ctx context.Context) (interface{}, error) { + op, err := types.OperationFromContext(ctx) + if err != nil { + return nil, err + } + + // Creating a Prefix is done with a single location and VLAN only, despite the API returning arrays. + if op == types.OperationCreate { + opts, err := types.OptionsFromContext(ctx) + if err != nil { + return nil, err + } + + if len(p.Locations) != 1 { + return nil, fmt.Errorf("%w: %v locations given", ErrLocationCount, len(p.Locations)) + } + + if len(p.VLANs) > 1 { + return nil, fmt.Errorf("%w: %v VLANs given", ErrVLANCount, len(p.VLANs)) + } + + data := struct { + Prefix + CreateEmpty *bool `json:"create_empty,omitempty"` + Location string `json:"location"` + VLAN string `json:"vlan,omitempty"` + }{ + Prefix: *p, + Location: p.Locations[0].Identifier, + } + + if len(p.VLANs) == 1 { + data.VLAN = p.VLANs[0].Identifier + } + + if ce, err := opts.Get(optionKeyCreateEmpty); err == nil { + v := ce.(bool) + data.CreateEmpty = &v + } + + data.Prefix.Locations = nil + data.Prefix.VLANs = nil + + return data, nil + } + + return p, nil +} diff --git a/pkg/apis/ipam/v1/prefix_options.go b/pkg/apis/ipam/v1/prefix_options.go new file mode 100644 index 00000000..b79ab009 --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_options.go @@ -0,0 +1,23 @@ +package v1 + +import ( + "go.anx.io/go-anxcloud/pkg/api/types" +) + +const ( + optionKeyCreateEmpty = "ipamv1/prefix/createEmpty" +) + +// CreateEmpty can be used to define if a Prefix is to be created with all Address objects +// in it created (false) or only Address objects created that are actually in use (true). +func CreateEmpty(empty bool) types.CreateOption { + return createEmptyOption(empty) +} + +type createEmptyOption bool + +func (ceo createEmptyOption) ApplyToCreate(opts *types.CreateOptions) { + // It can return an error when the requested key is already set, but overwriting is disabled. + // Since we have overwriting enabled, we can ignore the error. + _ = opts.Set(optionKeyCreateEmpty, bool(ceo), true) +} diff --git a/pkg/apis/ipam/v1/prefix_test.go b/pkg/apis/ipam/v1/prefix_test.go new file mode 100644 index 00000000..5a1174f7 --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_test.go @@ -0,0 +1,51 @@ +package v1 + +import ( + "context" + "net/url" + + "go.anx.io/go-anxcloud/pkg/api/types" + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Prefix filtered listing", func() { + DescribeTable("correctly sets query parameters", + func(p Prefix, queryData ...string) { + if len(queryData)%2 != 0 { + panic("invalid test case! queryData is a list of key-value-pairs, meaning it needs a even number of entries") + } + + ctx := types.ContextWithOperation(context.TODO(), types.OperationList) + u, err := p.EndpointURL(ctx) + Expect(err).NotTo(HaveOccurred()) + + expQuery := make(url.Values) + + for i, k := range queryData { + if i%2 == 1 { + continue + } + + v := queryData[i+1] + + expQuery.Set(k, v) + } + + Expect(u.Query()).To(BeEquivalentTo(expQuery)) + }, + Entry("no filter set", Prefix{}), + + Entry("version filter set for IPv4", Prefix{Version: FamilyIPv4}, "version", "4"), + Entry("version filter set for IPv6", Prefix{Version: FamilyIPv6}, "version", "6"), + + Entry("type filter set for public", Prefix{Type: AddressSpacePublic}, "type", "0"), + Entry("type filter set for private", Prefix{Type: AddressSpacePrivate}, "type", "1"), + + Entry("status filter set for StatusActive", Prefix{Status: StatusActive}, "status", "Active"), + + Entry("location filter set for a single Location", Prefix{Locations: []corev1.Location{{Identifier: "foo"}}}, "location", "foo"), + ) +}) diff --git a/pkg/apis/ipam/v1/prefix_types.go b/pkg/apis/ipam/v1/prefix_types.go new file mode 100644 index 00000000..6f27baac --- /dev/null +++ b/pkg/apis/ipam/v1/prefix_types.go @@ -0,0 +1,33 @@ +package v1 + +import ( + "errors" + + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + vlanv1 "go.anx.io/go-anxcloud/pkg/apis/vlan/v1" +) + +var ( + // ErrLocationCount is returned when trying to create a Prefix with no or more than one location. + ErrLocationCount = errors.New("Prefixes have to be created with exactly one Location") + + // ErrVLANCount is returned when trying to create a Prefix with more than one VLAN. + ErrVLANCount = errors.New("Prefixes have to be created with exactly one or none VLAN") +) + +// anxcloud:object + +type Prefix struct { + Identifier string `json:"identifier,omitempty" anxcloud:"identifier"` + Name string `json:"name,omitempty"` + DescriptionCustomer string `json:"description_customer"` + Version Family `json:"version,omitempty" anxcloud:"filterable"` + Netmask int `json:"netmask,omitempty"` + RoleText string `json:"role_text,omitempty"` + Status Status `json:"status,omitempty" anxcloud:"filterable"` + RouterRedundancy bool `json:"router_redundancy"` + Type AddressSpace `json:"type,omitempty" anxcloud:"filterable"` + + Locations []corev1.Location `json:"locations,omitempty" anxcloud:"filterable,location,single"` + VLANs []vlanv1.VLAN `json:"vlans,omitempty"` +} diff --git a/pkg/apis/ipam/v1/suite_test.go b/pkg/apis/ipam/v1/suite_test.go new file mode 100644 index 00000000..a2d9539d --- /dev/null +++ b/pkg/apis/ipam/v1/suite_test.go @@ -0,0 +1,13 @@ +package v1 + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIPAM(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "test suite for IPAM API definition") +} diff --git a/pkg/apis/ipam/v1/xxgenerated_object_test.go b/pkg/apis/ipam/v1/xxgenerated_object_test.go new file mode 100644 index 00000000..36442f59 --- /dev/null +++ b/pkg/apis/ipam/v1/xxgenerated_object_test.go @@ -0,0 +1,32 @@ +package v1 + +import ( + . "github.com/onsi/ginkgo/v2" + testutils "go.anx.io/go-anxcloud/pkg/utils/test" + + "go.anx.io/go-anxcloud/pkg/api/types" +) + +var _ = Describe("Object Address", func() { + o := Address{} + + ifaces := make([]interface{}, 0, 1) + { + var i types.Object + ifaces = append(ifaces, &i) + } + + testutils.ObjectTests(&o, ifaces...) +}) + +var _ = Describe("Object Prefix", func() { + o := Prefix{} + + ifaces := make([]interface{}, 0, 1) + { + var i types.Object + ifaces = append(ifaces, &i) + } + + testutils.ObjectTests(&o, ifaces...) +})