diff --git a/.gitignore b/.gitignore index dcefd75..6a93cee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ +.env /vendor/ bin/ diff --git a/router.go b/router.go index 9adbfdf..e029325 100644 --- a/router.go +++ b/router.go @@ -43,14 +43,20 @@ type RouterCreateRequest struct { InternetGateway bool `json:"internet_gateway"` } -// RouterUpdateRequest is not implemented yet because the API is not implemented yet -type RouterUpdateRequest struct{} +type RouterUpdateRequest struct { + ZonalResourceRequest + TaggedResourceRequest + Name string `json:"name,omitempty"` + // bool + omitempty: If we don't use a pointer to bool here, the value `false` causes the attribute to be omitted. + // This means setting the `internet_gateway` to false would cause an empty request body and thus a 400 Bad Request. + InternetGateway *bool `json:"internet_gateway,omitempty"` +} type RouterService interface { GenericCreateService[Router, RouterCreateRequest] GenericGetService[Router] GenericListService[Router] - // GenericUpdateService[Router, RouterUpdateRequest] + GenericUpdateService[Router, RouterUpdateRequest] GenericDeleteService[Router] GenericWaitForService[Router] // CreateInterface creates a new interface attached to this router diff --git a/router_test.go b/router_test.go index bd92eb3..a9080a7 100644 --- a/router_test.go +++ b/router_test.go @@ -1,6 +1,7 @@ package cloudscale import ( + "context" "encoding/json" "fmt" "net/http" @@ -48,6 +49,40 @@ func TestRouters_Create(t *testing.T) { } } +func TestRouters_Update(t *testing.T) { + setup() + defer teardown() + + routerID := "cfde831a-4e87-4a75-960f-89b0148aa2cc" + newRouterName := "new-router-name" + updateRouterRequest := &RouterUpdateRequest{ + Name: newRouterName, + } + + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodPatch) + + expected := map[string]any{ + "name": newRouterName, + } + + var v map[string]any + err := json.NewDecoder(r.Body).Decode(&v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + if !reflect.DeepEqual(v, expected) { + t.Errorf("Request body = %#v, expected %#v", v, expected) + } + }) + + err := client.Routers.Update(context.TODO(), routerID, updateRouterRequest) + if err != nil { + t.Errorf("Routers.Update returned error: %v", err) + } +} + func TestRouters_Get(t *testing.T) { setup() defer teardown() diff --git a/test/integration/cloudscale_test.go b/test/integration/cloudscale_test.go index 48b4c60..f5a01eb 100644 --- a/test/integration/cloudscale_test.go +++ b/test/integration/cloudscale_test.go @@ -260,7 +260,7 @@ func DeleteRemainingRouters() bool { } err = client.Routers.Delete(context.Background(), router.UUID) if err != nil { - log.Fatalf("Routers.Delete returned error %s\n", err) + log.Fatalf("Routers.Delete returned error: %s\n", err) } } } diff --git a/test/integration/helper_test.go b/test/integration/helper_test.go index a0f6e04..d654426 100644 --- a/test/integration/helper_test.go +++ b/test/integration/helper_test.go @@ -4,13 +4,9 @@ package integration import ( "context" - "errors" "math/rand" "reflect" "testing" - "time" - - "github.com/cenkalti/backoff/v5" "github.com/cloudscale-ch/cloudscale-go-sdk/v10" ) @@ -50,23 +46,3 @@ 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 index de6dce9..f1df020 100644 --- a/test/integration/routers_integration_test.go +++ b/test/integration/routers_integration_test.go @@ -3,49 +3,110 @@ package integration import ( - "errors" - "net/http" + "fmt" "testing" "time" "github.com/cloudscale-ch/cloudscale-go-sdk/v10" ) -func TestIntegrationRouter_CR_D(t *testing.T) { - t.Parallel() +func testCreateRouter(t *testing.T) cloudscale.Router { + t.Helper() createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: testRunPrefix, + Name: fmt.Sprintf("%s-%s", testRunPrefix, "router.example.com"), InternetGateway: true, ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, } - expected, err := client.Routers.Create(t.Context(), createRouterRequest) + initialRouter, 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 initialRouter.Name != createRouterRequest.Name { + t.Errorf("Router.Name got=%s\nwant=%s", initialRouter.Name, createRouterRequest.Name) + } + + if initialRouter.InternetGateway != createRouterRequest.InternetGateway { + t.Errorf("Router.InternetGateway got=%t\nwant=%t", initialRouter.InternetGateway, createRouterRequest.InternetGateway) + } + + if _, err := client.Routers.WaitFor(t.Context(), initialRouter.UUID, cloudscale.RouterIsActive); err != nil { + t.Errorf("router not in active state: %s", err) + } + + if h := time.Since(initialRouter.CreatedAt).Hours(); !(-1 < h && h < 1) { + t.Errorf("router.CreatedAt outside of expected range. got=%s", initialRouter.CreatedAt) + } + + // The number of InternetGatewayAddresses should be 1, since InternetGateway was updated to true. + // This assertion must be updated after the API supports IPv6 for private networks. + if numInternetGatewayAddresses := len(initialRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 1) + } + + // If InternetGateway is true and the Name is a valid FQDN, all InternetGatewayAddresses should have this FQDN as a reverse pointer. + for index, address := range initialRouter.InternetGatewayAddresses { + reversePTR := "" + if address.ReversePTR != nil { + reversePTR = *address.ReversePTR + } + + if reversePTR != initialRouter.Name { + t.Errorf("Router.InternetGatewayAddresses[%d].ReversePTR got=%s\nwant=%s", index, reversePTR, initialRouter.Name) + } + } + + return *initialRouter +} + +func testUpdateRouter(t *testing.T, initialRouter cloudscale.Router) cloudscale.Router { + t.Helper() + + updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ + Name: fmt.Sprintf("%s.%s", testRunPrefix, "offline-router"), + } + + err := client.Routers.Update(t.Context(), initialRouter.UUID, updateNameRouterRequest) if err != nil { - t.Fatalf("Routers.Get returned error %s", err) + t.Fatalf("Routers.Update returned error %s", err) } - if uuid := router.UUID; uuid != expected.UUID { - t.Errorf("Router.UUID got=%s\nwant=%s", uuid, expected.UUID) + updateInternetGatewayRouterRequest := &cloudscale.RouterUpdateRequest{ + InternetGateway: new(false), } - if h := time.Since(router.CreatedAt).Hours(); !(-1 < h && h < 1) { - t.Errorf("router.CreatedAt outside of expected range. got=%v", router.CreatedAt) + err = client.Routers.Update(t.Context(), initialRouter.UUID, updateInternetGatewayRouterRequest) + if err != nil { + t.Fatalf("Routers.Update returned error %s", err) } - if !router.InternetGateway { - t.Errorf("router.InternetGateway got=%v\nwant=%v", router.InternetGateway, true) + updatedRouter, err := client.Routers.Get(t.Context(), initialRouter.UUID) + if err != nil { + t.Fatalf("Routers.Get returned error %s", err) } - if _, err := client.Routers.WaitFor(t.Context(), router.UUID, cloudscale.RouterIsActive); err != nil { - t.Errorf("router not in active state: %v", err) + if updatedRouter.Name != updateNameRouterRequest.Name { + t.Errorf("router.Name got=%s\nwant=%s", updatedRouter.Name, updateNameRouterRequest.Name) + } + if updatedRouter.InternetGateway != *updateInternetGatewayRouterRequest.InternetGateway { + t.Errorf("router.InternetGateway got=%t\nwant=%t", updatedRouter.InternetGateway, *updateInternetGatewayRouterRequest.InternetGateway) + } + if uuid := updatedRouter.UUID; uuid != initialRouter.UUID { + t.Errorf("Router.UUID got=%s\nwant=%s", uuid, initialRouter.UUID) + } + // The number of InternetGatewayAddresses should be 0, since InternetGateway is set to false + if numInternetGatewayAddresses := len(updatedRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 0) } + return *updatedRouter +} + +func testListRouters(t *testing.T) { + t.Helper() + routers, err := client.Routers.List(t.Context()) if err != nil { t.Fatalf("Routers.List returned error %s\n", err) @@ -54,8 +115,11 @@ func TestIntegrationRouter_CR_D(t *testing.T) { if numRouters := len(routers); numRouters < 1 { t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) } +} + +func createNetwork(t *testing.T) cloudscale.Network { + t.Helper() - // Set up a network with a subnet so we can attach an interface to the router. createNetworkRequest := &cloudscale.NetworkCreateRequest{ Name: testRunPrefix, AutoCreateIPV4Subnet: new(false), @@ -66,6 +130,12 @@ func TestIntegrationRouter_CR_D(t *testing.T) { t.Fatalf("Networks.Create returned error %s", err) } + return *network +} + +func createSubnet(t *testing.T, network cloudscale.Network) cloudscale.Subnet { + t.Helper() + createSubnetRequest := &cloudscale.SubnetCreateRequest{ Network: network.UUID, CIDR: "192.168.99.0/24", @@ -75,8 +145,14 @@ func TestIntegrationRouter_CR_D(t *testing.T) { t.Fatalf("Subnets.Create returned error %s", err) } - createInterfaceRequest := cloudscale.CreateInterfaceRequest{ - Network: network.UUID, + return *subnet +} + +func testCreateRouterInterface(t *testing.T, router cloudscale.Router, subnet cloudscale.Subnet) cloudscale.RouterInterface { + t.Helper() + + createRouterInterfaceRequest := cloudscale.CreateInterfaceRequest{ + Network: subnet.Network.UUID, Addresses: []cloudscale.CreateAddressRequest{ { Subnet: subnet.UUID, @@ -84,75 +160,77 @@ func TestIntegrationRouter_CR_D(t *testing.T) { }, }, } - iface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + + routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createRouterInterfaceRequest) if err != nil { t.Fatalf("Routers.CreateInterface returned error %s", err) } - if iface.UUID == "" { + if routerInterface.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 networkUUID := routerInterface.Network.UUID; networkUUID != subnet.Network.UUID { + t.Errorf("interface.Network.UUID got=%s\nwant=%s", networkUUID, subnet.Network.UUID) } - if numAddresses := len(iface.Addresses); numAddresses != 1 { - t.Fatalf("interface Addresses got=%d\nwant=%d", numAddresses, 1) + if numAddresses := len(routerInterface.Addresses); numAddresses != 1 { + t.Errorf("interface Addresses got=%d\nwant=%d", numAddresses, 1) } - if subnetUUID := iface.Addresses[0].Subnet.UUID; subnetUUID != subnet.UUID { + if subnetUUID := routerInterface.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" { + if addr := routerInterface.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) - } + return *routerInterface +} - // 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 - }) +func testDeleteRouterInterface(t *testing.T, router cloudscale.Router, routerInterface cloudscale.RouterInterface) { + t.Helper() + + err := client.Routers.DeleteInterface(t.Context(), router.UUID, routerInterface.UUID) if err != nil { - t.Errorf("waiting for interface delete failed: %v", err) + t.Errorf("Routers.DeleteInterface returned error: %s", err) } +} + +func testDeleteRouter(t *testing.T, router cloudscale.Router) { + t.Helper() - // Clean up: delete router - err = client.Routers.Delete(t.Context(), router.UUID) + 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) - } +func deleteNetwork(t *testing.T, network cloudscale.Network) { + t.Helper() - // Clean up: delete network - err = client.Networks.Delete(t.Context(), network.UUID) + err := client.Networks.Delete(t.Context(), network.UUID) if err != nil { t.Fatalf("Networks.Delete returned error %s", err) } } + +func TestIntegrationRouter_CRUD(t *testing.T) { + t.Parallel() + + router := testCreateRouter(t) + router = testUpdateRouter(t, router) + testListRouters(t) + + // Set up a network with a subnet so we can attach an interface to the router. + network := createNetwork(t) + subnet := createSubnet(t, network) + + routerInterface := testCreateRouterInterface(t, router, subnet) + + // Clean up and test router interface deletion + testDeleteRouterInterface(t, router, routerInterface) + + // Clean up and test router deletion + testDeleteRouter(t, router) + + // Clean up: delete network (automatically deletes the subnet too) + deleteNetwork(t, network) +}