diff --git a/cloudscale.go b/cloudscale.go index 2afb66b..f9599bc 100644 --- a/cloudscale.go +++ b/cloudscale.go @@ -40,6 +40,7 @@ type Client struct { VolumeSnapshots VolumeSnapshotService Networks NetworkService Subnets SubnetService + Routers RouterService FloatingIPs FloatingIPsService ServerGroups ServerGroupService ObjectsUsers ObjectsUsersService @@ -86,6 +87,13 @@ func NewClient(httpClient *http.Client) *Client { client: c, path: subnetBasePath, } + c.Routers = RouterServiceOperations{ + GenericServiceOperations: GenericServiceOperations[Router, RouterCreateRequest, RouterUpdateRequest]{ + client: c, + path: routerBasePath, + }, + client: c, + } c.FloatingIPs = GenericServiceOperations[FloatingIP, FloatingIPCreateRequest, FloatingIPUpdateRequest]{ client: c, path: floatingIPsBasePath, diff --git a/router.go b/router.go new file mode 100644 index 0000000..3d44adb --- /dev/null +++ b/router.go @@ -0,0 +1,115 @@ +package cloudscale + +import ( + "context" + "fmt" + "net/http" + "time" +) + +const routerBasePath = "v1/routers" + +type Router struct { + ZonalResource + TaggedResource + HREF string `json:"href"` + UUID string `json:"uuid"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Status string `json:"status"` + InternetGateway bool `json:"internet_gateway"` + InternetGatewayAddresses []IPAddress `json:"internet_gateway_addresses,omitempty"` + InternalInterfaces []InternalInterface `json:"internal_interfaces,omitempty"` +} + +type IPAddress struct { + Address string `json:"address"` + Subnet SubnetStub `json:"subnet"` + Version int `json:"version"` + ReversePTR *string `json:"reverse_ptr"` +} +type InternalInterface struct { + UUID string `json:"uuid"` + Network NetworkStub `json:"network"` + Addresses []IPAddress `json:"addresses"` + Type string `json:"type"` + MACAddress string `json:"mac_address"` +} + +type RouterCreateRequest struct { + ZonalResourceRequest + TaggedResourceRequest + Name string `json:"name"` + InternetGateway bool `json:"internet_gateway"` +} + +// RouterUpdateRequest is not implemented yet because the API is not implemented yet +type RouterUpdateRequest struct{} + +type RouterService interface { + GenericCreateService[Router, RouterCreateRequest] + GenericGetService[Router] + GenericListService[Router] + // GenericUpdateService[Router, RouterUpdateRequest] + GenericDeleteService[Router] + GenericWaitForService[Router] + // CreateInterface creates a new interface attached to this router + CreateInterface(ctx context.Context, routerUUID string, createReq CreateInterfaceRequest) (*InternalInterface, error) + // DeleteInterface removes an interface attached to this router + DeleteInterface(ctx context.Context, routerUUID, interfaceUUID string) error +} + +type CreateInterfaceRequest struct { + Network string `json:"network"` + Addresses []CreateAddressRequest `json:"addresses"` +} +type CreateAddressRequest struct { + Subnet string `json:"subnet"` + Address string `json:"address"` +} + +type RouterServiceOperations struct { + GenericServiceOperations[Router, RouterCreateRequest, RouterUpdateRequest] + client *Client +} + +func (r RouterServiceOperations) CreateInterface(ctx context.Context, routerUUID string, createReq CreateInterfaceRequest) (*InternalInterface, error) { + path := fmt.Sprintf("%s/%s/create_interface", routerBasePath, routerUUID) + ctx = WithOperationPath(ctx, routerBasePath+"/:id/create_interface") + req, err := r.client.NewRequest(ctx, http.MethodPost, path, createReq) + if err != nil { + return nil, err + } + res := &InternalInterface{} + if err := r.client.Do(ctx, req, res); err != nil { + return nil, err + } + return res, nil +} + +func (r RouterServiceOperations) DeleteInterface(ctx context.Context, routerUUID, interfaceUUID string) error { + path := fmt.Sprintf("%s/%s/delete_interface", routerBasePath, routerUUID) + ctx = WithOperationPath(ctx, routerBasePath+"/:id/delete_interface") + + type DeleteInterfaceRequest struct { + Interface string `json:"interface"` + } + deleteReq := DeleteInterfaceRequest{Interface: interfaceUUID} + + req, err := r.client.NewRequest(ctx, http.MethodPost, path, deleteReq) + if err != nil { + return err + } + return r.client.Do(ctx, req, nil) +} + +const ( + RouterActive = "active" +) + +var RouterIsActive = func(router *Router) (bool, error) { + if router.Status == RouterActive { + return true, nil + } + return false, fmt.Errorf("waiting for status: %s, current status: %s", RouterActive, router.Status) +} diff --git a/router_test.go b/router_test.go new file mode 100644 index 0000000..680ebf6 --- /dev/null +++ b/router_test.go @@ -0,0 +1,326 @@ +package cloudscale + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" + "time" +) + +func TestRouters_Create(t *testing.T) { + setup() + defer teardown() + + routerRequest := &RouterCreateRequest{ + Name: "gw", + InternetGateway: true, + } + + mux.HandleFunc("/v1/routers", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodPost) + + expected := map[string]any{ + "name": "gw", + "internet_gateway": true, + } + + var v map[string]any + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + t.Fatalf("decode json: %v", err) + } + + if !reflect.DeepEqual(v, expected) { + t.Errorf("Request body\n got=%#v\nwant=%#v", v, expected) + } + + _, _ = fmt.Fprint(w, `{"uuid": "42cec963-fcd2-482f-bdb6-24461b2d47b1"}`) + }) + + router, err := client.Routers.Create(ctx, routerRequest) + if err != nil { + t.Errorf("Routers.Create returned error: %v", err) + } + + if id := router.UUID; id != "42cec963-fcd2-482f-bdb6-24461b2d47b1" { + t.Errorf("expected id '42cec963-fcd2-482f-bdb6-24461b2d47b1', received '%s'", id) + } +} + +func TestRouters_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodGet) + _, _ = fmt.Fprint(w, `{ + "href": "https://api.cloudscale.ch/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", + "uuid": "cfde831a-4e87-4a75-960f-89b0148aa2cc", + "name": "gw", + "zone": {"slug": "lpg1"}, + "created_at": "2019-05-27T16:45:32.241824Z", + "status": "up", + "internet_gateway": true, + "internet_gateway_addresses": [ + { + "address": "203.0.113.1", + "subnet": { + "href": "https://api.cloudscale.ch/v1/subnets/8a04e678-4f1c-4d5f-9e40-8f0eaf1d0e0d", + "cidr": "203.0.113.0/24", + "uuid": "8a04e678-4f1c-4d5f-9e40-8f0eaf1d0e0d" + }, + "version": 4, + "reverse_ptr": "203-0-113-1.cust.example.com" + }, + { + "address": "2001:db8::1", + "subnet": { + "href": "https://api.cloudscale.ch/v1/subnets/9204e678-4f1c-4d5f-9e40-8f0eaf1d0eaa", + "cidr": "2001:db8::/32", + "uuid": "9204e678-4f1c-4d5f-9e40-8f0eaf1d0eaa" + }, + "version": 6, + "reverse_ptr": "203-0-113-1.cust.example.com" + } + ], + "internal_interfaces": [ + { + "uuid": "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", + "network": { + "href": "https://api.cloudscale.ch/v1/networks/7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + "name": "my-network", + "uuid": "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d" + }, + "addresses": [ + { + "address": "10.0.0.1", + "subnet": { + "href": "https://api.cloudscale.ch/v1/subnets/3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + "cidr": "10.0.0.0/24", + "uuid": "3d6ca1f4-5aea-41f5-b724-0f3054b60e85" + }, + "version": 4, + "reverse_ptr": null + } + ], + "type": "private", + "mac_address": "00:00:5e:00:53:ab" + } + ] + }`) + }) + + router, err := client.Routers.Get(ctx, "cfde831a-4e87-4a75-960f-89b0148aa2cc") + if err != nil { + t.Errorf("Routers.Get returned error: %v", err) + } + + expected := &Router{ + ZonalResource: ZonalResource{ + Zone: ZoneStub{Slug: "lpg1"}, + }, + HREF: "https://api.cloudscale.ch/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", + UUID: "cfde831a-4e87-4a75-960f-89b0148aa2cc", + Name: "gw", + CreatedAt: time.Date(2019, time.Month(5), 27, 16, 45, 32, 241824000, time.UTC), + Status: "up", + InternetGateway: true, + InternetGatewayAddresses: []IPAddress{ + { + Address: "203.0.113.1", + Subnet: SubnetStub{ + HREF: "https://api.cloudscale.ch/v1/subnets/8a04e678-4f1c-4d5f-9e40-8f0eaf1d0e0d", + CIDR: "203.0.113.0/24", + UUID: "8a04e678-4f1c-4d5f-9e40-8f0eaf1d0e0d", + }, + Version: 4, + ReversePTR: new("203-0-113-1.cust.example.com"), + }, + { + Address: "2001:db8::1", + Subnet: SubnetStub{ + HREF: "https://api.cloudscale.ch/v1/subnets/9204e678-4f1c-4d5f-9e40-8f0eaf1d0eaa", + CIDR: "2001:db8::/32", + UUID: "9204e678-4f1c-4d5f-9e40-8f0eaf1d0eaa", + }, + Version: 6, + ReversePTR: new("203-0-113-1.cust.example.com"), + }, + }, + InternalInterfaces: []InternalInterface{ + { + UUID: "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", + Network: NetworkStub{ + HREF: "https://api.cloudscale.ch/v1/networks/7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + Name: "my-network", + UUID: "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + }, + Addresses: []IPAddress{ + { + Address: "10.0.0.1", + Subnet: SubnetStub{ + HREF: "https://api.cloudscale.ch/v1/subnets/3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + CIDR: "10.0.0.0/24", + UUID: "3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + }, + Version: 4, + ReversePTR: nil, + }, + }, + Type: "private", + MACAddress: "00:00:5e:00:53:ab", + }, + }, + } + + if !reflect.DeepEqual(router, expected) { + t.Errorf("Routers.Get\n got=%#v\nwant=%#v", router, expected) + } +} + +func TestRouters_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v1/routers", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodGet) + _, _ = fmt.Fprint(w, `[{"uuid": "47cec963-fcd2-482f-bdb6-24461b2d47b1"}]`) + }) + + routers, err := client.Routers.List(ctx) + if err != nil { + t.Errorf("Routers.List returned error: %v", err) + } + + expected := []Router{{UUID: "47cec963-fcd2-482f-bdb6-24461b2d47b1"}} + if !reflect.DeepEqual(routers, expected) { + t.Errorf("Routers.List\n got=%#v\nwant=%#v", routers, expected) + } +} + +func TestRouters_CreateInterface(t *testing.T) { + setup() + defer teardown() + + createReq := CreateInterfaceRequest{ + Network: "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + Addresses: []CreateAddressRequest{ + { + Subnet: "3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + Address: "10.0.0.1", + }, + }, + } + + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc/create_interface", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodPost) + + expected := map[string]any{ + "network": "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + "addresses": []any{ + map[string]any{ + "subnet": "3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + "address": "10.0.0.1", + }, + }, + } + + var v map[string]any + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + t.Fatalf("decode json: %v", err) + } + + if !reflect.DeepEqual(v, expected) { + t.Errorf("Request body\n got=%#v\nwant=%#v", v, expected) + } + + _, _ = fmt.Fprint(w, `{ + "uuid": "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", + "network": { + "href": "https://api.cloudscale.ch/v1/networks/7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + "name": "my-network", + "uuid": "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d" + }, + "addresses": [ + { + "address": "10.0.0.1", + "subnet": { + "href": "https://api.cloudscale.ch/v1/subnets/3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + "cidr": "10.0.0.0/24", + "uuid": "3d6ca1f4-5aea-41f5-b724-0f3054b60e85" + }, + "version": 4, + "reverse_ptr": null + } + ], + "type": "vip", + "mac_address": "aa:bb:cc:dd:ee:ff" + }`) + }) + + iface, err := client.Routers.CreateInterface(ctx, "cfde831a-4e87-4a75-960f-89b0148aa2cc", createReq) + if err != nil { + t.Errorf("Routers.CreateInterface returned error: %v", err) + } + + expected := &InternalInterface{ + UUID: "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", + Network: NetworkStub{ + HREF: "https://api.cloudscale.ch/v1/networks/7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + Name: "my-network", + UUID: "7f0eaf1d-0e0d-4d5f-9e40-8f0eaf1d0e0d", + }, + Addresses: []IPAddress{ + { + Address: "10.0.0.1", + Subnet: SubnetStub{ + HREF: "https://api.cloudscale.ch/v1/subnets/3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + CIDR: "10.0.0.0/24", + UUID: "3d6ca1f4-5aea-41f5-b724-0f3054b60e85", + }, + Version: 4, + ReversePTR: nil, + }, + }, + Type: "vip", + MACAddress: "aa:bb:cc:dd:ee:ff", + } + + if !reflect.DeepEqual(iface, expected) { + t.Errorf("Routers.CreateInterface\n got=%#v\nwant=%#v", iface, expected) + } +} + +func TestRouters_DeleteInterface(t *testing.T) { + setup() + defer teardown() + + called := false + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc/delete_interface", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodPost) + + expected := map[string]any{ + "interface": "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", + } + + var v map[string]any + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + t.Fatalf("decode json: %v", err) + } + + if !reflect.DeepEqual(v, expected) { + t.Errorf("Request body\n got=%#v\nwant=%#v", v, expected) + } + called = true + }) + + err := client.Routers.DeleteInterface(ctx, "cfde831a-4e87-4a75-960f-89b0148aa2cc", "1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d") + if err != nil { + t.Errorf("Routers.DeleteInterface returned error: %v", err) + } + + if !called { + t.Error("expected delete_interface endpoint to be called") + } +} diff --git a/test/integration/cloudscale_test.go b/test/integration/cloudscale_test.go index 8d3ea96..7044715 100644 --- a/test/integration/cloudscale_test.go +++ b/test/integration/cloudscale_test.go @@ -49,6 +49,7 @@ func TestMain(m *testing.M) { foundResource = foundResource || DeleteRemainingServerGroups() foundResource = foundResource || DeleteRemainingVolumeSnapshots() foundResource = foundResource || DeleteRemainingVolumes() + foundResource = foundResource || DeleteRemainingRouters() foundResource = foundResource || DeleteRemainingSubnets() foundResource = foundResource || DeleteRemainingNetworks() foundResource = foundResource || DeleteRemainingObjectsUsers() @@ -237,6 +238,28 @@ func DeleteRemainingCustomImages() bool { return foundResource } +func DeleteRemainingRouters() bool { + foundResource := false + + routers, err := client.Routers.List(context.Background()) + if err != nil { + log.Fatalf("Routers.List returned error %s\n", err) + } + + for _, router := range routers { + if strings.HasPrefix(router.Name, testRunPrefix) { + foundResource = true + log.Printf("Found not deleted router: %s (%s)\n", router.Name, router.UUID) + err = client.Routers.Delete(context.Background(), router.UUID) + if err != nil { + log.Fatalf("Routers.Delete returned error %s\n", err) + } + } + } + + return foundResource +} + func DeleteRemainingLoadBalancers() bool { foundResource := false diff --git a/test/integration/helper.go b/test/integration/helper_test.go similarity index 64% rename from test/integration/helper.go rename to test/integration/helper_test.go index f268b96..8004f57 100644 --- a/test/integration/helper.go +++ b/test/integration/helper_test.go @@ -4,9 +4,13 @@ package integration import ( "context" + "errors" "math/rand" "reflect" "testing" + "time" + + "github.com/cenkalti/backoff/v5" "github.com/cloudscale-ch/cloudscale-go-sdk/v9" ) @@ -46,3 +50,23 @@ func assertEqual(t *testing.T, expected any, actual any) { t.Errorf("Assertion failed:\nexpected: %#v\n actual: %#v", expected, actual) } } + +// waitForDeleted calls existsFunc in a backoff loop until exists is false. +func waitForDeleted(ctx context.Context, existsFunc func() (exists bool, err error)) error { + options := []backoff.RetryOption{ + backoff.WithBackOff(backoff.NewConstantBackOff(2 * time.Second)), + backoff.WithMaxElapsedTime(5 * time.Minute), + } + + _, err := backoff.Retry(ctx, func() (struct{}, error) { + exists, err := existsFunc() + if !exists { + return struct{}{}, nil + } + if err == nil { + return struct{}{}, errors.New("resource not deleted yet") + } + return struct{}{}, err + }, options...) + return err +} diff --git a/test/integration/routers_integration_test.go b/test/integration/routers_integration_test.go new file mode 100644 index 0000000..ab57e3b --- /dev/null +++ b/test/integration/routers_integration_test.go @@ -0,0 +1,134 @@ +//go:build integration + +package integration + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" +) + +func TestIntegrationRouter_CRUD(t *testing.T) { + t.Parallel() + + createRouterRequest := &cloudscale.RouterCreateRequest{ + Name: testRunPrefix, + InternetGateway: true, + ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, + } + + expected, err := client.Routers.Create(t.Context(), createRouterRequest) + if err != nil { + t.Fatalf("Routers.Create returned error %s", err) + } + + router, err := client.Routers.Get(t.Context(), expected.UUID) + if err != nil { + t.Fatalf("Routers.Get returned error %s", err) + } + + if uuid := router.UUID; uuid != expected.UUID { + t.Errorf("Router.UUID got=%s\nwant=%s", uuid, expected.UUID) + } + + if h := time.Since(router.CreatedAt).Hours(); !(-1 < h && h < 1) { + t.Errorf("router.CreatedAt outside of expected range. got=%v", router.CreatedAt) + } + + if !router.InternetGateway { + t.Errorf("router.InternetGateway got=%v\nwant=%v", router.InternetGateway, true) + } + + if _, err := client.Routers.WaitFor(t.Context(), router.UUID, cloudscale.RouterIsActive); err != nil { + t.Errorf("router not in active state: %v", err) + } + + routers, err := client.Routers.List(t.Context()) + if err != nil { + t.Fatalf("Routers.List returned error %s\n", err) + } + + if numRouters := len(routers); numRouters != 1 { + t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) + } + + // Set up a network with a subnet so we can attach an interface to the router. + createNetworkRequest := &cloudscale.NetworkCreateRequest{ + Name: testRunPrefix, + AutoCreateIPV4Subnet: new(false), + } + network, err := client.Networks.Create(t.Context(), createNetworkRequest) + if err != nil { + t.Fatalf("Networks.Create returned error %s", err) + } + + createSubnetRequest := &cloudscale.SubnetCreateRequest{ + Network: network.UUID, + CIDR: "192.168.99.0/24", + } + subnet, err := client.Subnets.Create(t.Context(), createSubnetRequest) + if err != nil { + t.Fatalf("Subnets.Create returned error %s", err) + } + + createInterfaceRequest := cloudscale.CreateInterfaceRequest{ + Network: network.UUID, + Addresses: []cloudscale.CreateAddressRequest{ + { + Subnet: subnet.UUID, + Address: "192.168.99.10", + }, + }, + } + iface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + if err != nil { + t.Fatalf("Routers.CreateInterface returned error %s", err) + } + + if iface.UUID == "" { + t.Error("Routers.CreateInterface returned interface without UUID") + } + if networkUUID := iface.Network.UUID; networkUUID != network.UUID { + t.Errorf("interface.Network.UUID got=%s\nwant=%s", networkUUID, network.UUID) + } + if numAddresses := len(iface.Addresses); numAddresses != 1 { + t.Fatalf("interface Addresses got=%d\nwant=%d", numAddresses, 1) + } + if subnetUUID := iface.Addresses[0].Subnet.UUID; subnetUUID != subnet.UUID { + t.Errorf("interface.Addresses[0].Subnet.UUID got=%s\nwant=%s", subnetUUID, subnet.UUID) + } + if addr := iface.Addresses[0].Address; addr != "192.168.99.10" { + t.Errorf("interface.Addresses[0].Address got=%s\nwant=%s", addr, "192.168.99.10") + } + + // Clean up: only remove router and, once it's deleted, the network. Sub-resources like interfaces attached to the router + // and subnets within the network are removed automatically. + + err = client.Routers.Delete(t.Context(), router.UUID) + if err != nil { + t.Fatalf("Routers.Delete returned error %s", err) + } + + err = waitForDeleted(t.Context(), func() (exists bool, err error) { + r, err := client.Routers.Get(t.Context(), router.UUID) + if err != nil { + if cerr, ok := errors.AsType[*cloudscale.ErrorResponse](err); ok && cerr.StatusCode == http.StatusNotFound { + return false, nil + } + return true, err + } + t.Logf("router %q still exists with status %q", r.UUID, r.Status) + return true, nil + }) + if err != nil { + t.Errorf("waiting for router delete failed: %v", err) + } + + err = client.Networks.Delete(t.Context(), network.UUID) + if err != nil { + t.Fatalf("Networks.Delete returned error %s", err) + } +}