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..9adbfdf --- /dev/null +++ b/router.go @@ -0,0 +1,110 @@ +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"` + Interfaces []RouterInterface `json:"interfaces,omitempty"` +} + +type IPAddress struct { + Address string `json:"address"` + Subnet SubnetStub `json:"subnet"` + Version int `json:"version"` + ReversePTR *string `json:"reverse_ptr"` +} +type RouterInterface 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) (*RouterInterface, 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) (*RouterInterface, error) { + path := fmt.Sprintf("%s/%s/interfaces", routerBasePath, routerUUID) + ctx = WithOperationPath(ctx, routerBasePath+"/:id/interfaces") + req, err := r.client.NewRequest(ctx, http.MethodPost, path, createReq) + if err != nil { + return nil, err + } + res := &RouterInterface{} + 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/interfaces/%s", routerBasePath, routerUUID, interfaceUUID) + ctx = WithOperationPath(ctx, routerBasePath+"/:id/interfaces/:interface_id") + + req, err := r.client.NewRequest(ctx, http.MethodDelete, path, nil) + 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..bd92eb3 --- /dev/null +++ b/router_test.go @@ -0,0 +1,314 @@ +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" + } + ], + "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"), + }, + }, + Interfaces: []RouterInterface{ + { + 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/interfaces", 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 := &RouterInterface{ + 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/interfaces/1e0c6f9c-9f0d-4d1b-9f0d-8f0eaf1d0e0d", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodDelete) + + 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/servers.go b/servers.go index ee747af..d3b7047 100644 --- a/servers.go +++ b/servers.go @@ -24,7 +24,7 @@ type Server struct { Flavor FlavorStub `json:"flavor"` Image ImageServerStub `json:"image"` Volumes []VolumeStub `json:"volumes"` - Interfaces []Interface `json:"interfaces"` + Interfaces []ServerInterface `json:"interfaces"` SSHFingerprints []string `json:"ssh_fingerprints"` SSHHostKeys []string `json:"ssh_host_keys"` AntiAfinityWith []ServerStub `json:"anti_affinity_with"` @@ -65,13 +65,13 @@ type VolumeStub struct { SizeGB int `json:"size_gb"` } -type Interface struct { - Type string `json:"type,omitempty"` - Network NetworkStub `json:"network,omitempty"` - Addresses []Address `json:"addresses,omitempty"` +type ServerInterface struct { + Type string `json:"type,omitempty"` + Network NetworkStub `json:"network,omitempty"` + Addresses []ServerAddress `json:"addresses,omitempty"` } -type Address struct { +type ServerAddress struct { Version int `json:"version"` Address string `json:"address"` PrefixLength int `json:"prefix_length"` @@ -83,30 +83,30 @@ type Address struct { type ServerRequest struct { ZonalResourceRequest TaggedResourceRequest - Name string `json:"name"` - Flavor string `json:"flavor"` - Image string `json:"image"` - Zone string `json:"zone,omitempty"` - VolumeSizeGB int `json:"volume_size_gb,omitempty"` - Volumes *[]ServerVolumeRequest `json:"volumes,omitempty"` - Interfaces *[]InterfaceRequest `json:"interfaces,omitempty"` - BulkVolumeSizeGB int `json:"bulk_volume_size_gb,omitempty"` - SSHKeys []string `json:"ssh_keys"` - Password string `json:"password,omitempty"` - UsePublicNetwork *bool `json:"use_public_network,omitempty"` - UsePrivateNetwork *bool `json:"use_private_network,omitempty"` - UseIPV6 *bool `json:"use_ipv6,omitempty"` - AntiAffinityWith string `json:"anti_affinity_with,omitempty"` - ServerGroups []string `json:"server_groups,omitempty"` - UserData string `json:"user_data,omitempty"` + Name string `json:"name"` + Flavor string `json:"flavor"` + Image string `json:"image"` + Zone string `json:"zone,omitempty"` + VolumeSizeGB int `json:"volume_size_gb,omitempty"` + Volumes *[]ServerVolumeRequest `json:"volumes,omitempty"` + Interfaces *[]ServerInterfaceRequest `json:"interfaces,omitempty"` + BulkVolumeSizeGB int `json:"bulk_volume_size_gb,omitempty"` + SSHKeys []string `json:"ssh_keys"` + Password string `json:"password,omitempty"` + UsePublicNetwork *bool `json:"use_public_network,omitempty"` + UsePrivateNetwork *bool `json:"use_private_network,omitempty"` + UseIPV6 *bool `json:"use_ipv6,omitempty"` + AntiAffinityWith string `json:"anti_affinity_with,omitempty"` + ServerGroups []string `json:"server_groups,omitempty"` + UserData string `json:"user_data,omitempty"` } type ServerUpdateRequest struct { TaggedResourceRequest - Name string `json:"name,omitempty"` - Status string `json:"status,omitempty"` - Flavor string `json:"flavor,omitempty"` - Interfaces *[]InterfaceRequest `json:"interfaces,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Flavor string `json:"flavor,omitempty"` + Interfaces *[]ServerInterfaceRequest `json:"interfaces,omitempty"` } type ServerVolumeRequest struct { @@ -114,7 +114,7 @@ type ServerVolumeRequest struct { Type string `json:"type,omitempty"` } -type InterfaceRequest struct { +type ServerInterfaceRequest struct { Network string `json:"network,omitempty"` Addresses *[]AddressRequest `json:"addresses,omitempty"` } diff --git a/test/integration/cloudscale_test.go b/test/integration/cloudscale_test.go index 8d3ea96..fce58a8 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,36 @@ 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) + // Interfaces must be removed before the router can be deleted. + for _, iface := range router.Interfaces { + log.Printf("Found not deleted router interface: %s on router %s\n", iface.UUID, router.UUID) + err = client.Routers.DeleteInterface(context.Background(), router.UUID, iface.UUID) + if err != nil { + log.Fatalf("Routers.DeleteInterface returned error %s\n", err) + } + } + 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/load_balancer_pool_members_integration_test.go b/test/integration/load_balancer_pool_members_integration_test.go index 40b8a1d..b13c67e 100644 --- a/test/integration/load_balancer_pool_members_integration_test.go +++ b/test/integration/load_balancer_pool_members_integration_test.go @@ -214,7 +214,7 @@ func TestIntegrationLoadBalancerPoolMember_MonitorStatus(t *testing.T) { // Step 2: Create a server on the private network serverRequest := getDefaultServerRequest() - serverRequest.Interfaces = &[]cloudscale.InterfaceRequest{{Network: network.UUID}} + serverRequest.Interfaces = &[]cloudscale.ServerInterfaceRequest{{Network: network.UUID}} serverRequest.SSHKeys = []string{} serverRequest.Password = randomNotVerySecurePassword(10) diff --git a/test/integration/networks_integration_test.go b/test/integration/networks_integration_test.go index 6891919..6b73281 100644 --- a/test/integration/networks_integration_test.go +++ b/test/integration/networks_integration_test.go @@ -112,16 +112,16 @@ func TestIntegrationNetwork_CreateAttached(t *testing.T) { cases := []struct { name string - in *[]cloudscale.InterfaceRequest + in *[]cloudscale.ServerInterfaceRequest expectedNumNetworks int expectedIP string }{ - {"Attach by network UUID", &[]cloudscale.InterfaceRequest{ + {"Attach by network UUID", &[]cloudscale.ServerInterfaceRequest{ { Network: network.UUID, }, }, 1, `192\.168\.42\.[0-9]*`}, - {"Attach by subnet UUID", &[]cloudscale.InterfaceRequest{ + {"Attach by subnet UUID", &[]cloudscale.ServerInterfaceRequest{ { Addresses: &[]cloudscale.AddressRequest{ { @@ -130,7 +130,7 @@ func TestIntegrationNetwork_CreateAttached(t *testing.T) { }, }, }, 1, `192\.168\.42\.[0-9]*`}, - {"Attach by subnet UUID with predefined IP", &[]cloudscale.InterfaceRequest{ + {"Attach by subnet UUID with predefined IP", &[]cloudscale.ServerInterfaceRequest{ { Addresses: &[]cloudscale.AddressRequest{ { @@ -140,7 +140,7 @@ func TestIntegrationNetwork_CreateAttached(t *testing.T) { }, }, }, 1, `192\.168\.42\.242`}, - {"Attach by network UUID without IP (Layer 2)", &[]cloudscale.InterfaceRequest{ + {"Attach by network UUID without IP (Layer 2)", &[]cloudscale.ServerInterfaceRequest{ { Network: "public", }, @@ -233,7 +233,7 @@ func TestIntegrationNetwork_Reattach(t *testing.T) { t.Fatalf("Subnets.Create returned error %s\n", err) } - interfaces := []cloudscale.InterfaceRequest{ + interfaces := []cloudscale.ServerInterfaceRequest{ {Network: "public"}, } createServerRequest := &cloudscale.ServerRequest{ @@ -268,7 +268,7 @@ func TestIntegrationNetwork_Reattach(t *testing.T) { Subnet: subnet.UUID, Address: "192.168.77.77", }} - interfaces = append(interfaces, cloudscale.InterfaceRequest{ + interfaces = append(interfaces, cloudscale.ServerInterfaceRequest{ Addresses: &addresses, }) updateRequest := cloudscale.ServerUpdateRequest{ @@ -323,7 +323,7 @@ func TestIntegrationNetwork_Reorder(t *testing.T) { t.Fatalf("Subnets.Create returned error %s\n", err) } - interfaces := []cloudscale.InterfaceRequest{ + interfaces := []cloudscale.ServerInterfaceRequest{ {Network: "public"}, {Network: network.UUID}, } diff --git a/test/integration/routers_integration_test.go b/test/integration/routers_integration_test.go new file mode 100644 index 0000000..8ed0bbb --- /dev/null +++ b/test/integration/routers_integration_test.go @@ -0,0 +1,157 @@ +//go:build integration + +package integration + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" +) + +func TestIntegrationRouter_CR_D(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: delete interface + if err := client.Routers.DeleteInterface(t.Context(), router.UUID, iface.UUID); err != nil { + t.Errorf("Routers.DeleteInterface returned error: %s", err) + } + + // Verify the interface is actually gone before deleting the router: the router's + // Interfaces list must no longer contain it. + err = waitForDeleted(t.Context(), func() (exists bool, err error) { + r, err := client.Routers.Get(t.Context(), router.UUID) + if err != nil { + return true, err + } + for _, i := range r.Interfaces { + if i.UUID == iface.UUID { + t.Logf("interface %q still attached to router %q", iface.UUID, router.UUID) + return true, nil + } + } + return false, nil + }) + if err != nil { + t.Errorf("waiting for interface delete failed: %v", err) + } + + // Clean up: delete router + 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) + } + + // Clean up: delete network + err = client.Networks.Delete(t.Context(), network.UUID) + if err != nil { + t.Fatalf("Networks.Delete returned error %s", err) + } +}