From 0876e38dea90c2df6aa5f0011608cd1fbcbec254 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Mon, 12 Oct 2026 00:51:39 +1100 Subject: [PATCH] Accept 202 for reverse name changes and add unit and destroy checks --- .../provider/server_reverse_name_resource.go | 6 +- .../server_reverse_name_resource_test.go | 53 +++++++ .../server_reverse_name_resource_unit_test.go | 141 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 internal/provider/server_reverse_name_resource_unit_test.go diff --git a/internal/provider/server_reverse_name_resource.go b/internal/provider/server_reverse_name_resource.go index 87a81bd..ff792f1 100644 --- a/internal/provider/server_reverse_name_resource.go +++ b/internal/provider/server_reverse_name_resource.go @@ -259,9 +259,13 @@ func (r *serverReverseNameResource) changeReverseName(ctx context.Context, serve if err != nil { return fmt.Errorf("error changing reverse name for server: server_id=%d, error: %w", serverId, err) } - if actionResp.StatusCode() != http.StatusOK { + if actionResp.StatusCode() != http.StatusOK && actionResp.StatusCode() != http.StatusAccepted { return fmt.Errorf("unexpected HTTP status code changing reverse name for server: server_id=%d, details: %s", serverId, actionResp.Body) } + // 202 Accepted carries no action ID to poll + if actionResp.JSON200 == nil { + return nil + } sr := serverResource{bc: r.bc} if err := sr.waitForServerAction(ctx, serverId, actionResp.JSON200.Action.Id); err != nil { diff --git a/internal/provider/server_reverse_name_resource_test.go b/internal/provider/server_reverse_name_resource_test.go index 1a047a7..1c6d615 100644 --- a/internal/provider/server_reverse_name_resource_test.go +++ b/internal/provider/server_reverse_name_resource_test.go @@ -1,8 +1,14 @@ package provider import ( + "context" + "fmt" + "net/http" + "strconv" "testing" + "terraform-provider-binarylane/internal/binarylane" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" ) @@ -47,8 +53,14 @@ resource "binarylane_server" "test" { } ` + var serverId int64 + var ipv4Address string + resource.ParallelTest(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: func(_ *terraform.State) error { + return testAccCheckServerReverseNameCleared(serverId, ipv4Address) + }, Steps: []resource.TestStep{ // Create and Read testing { @@ -64,6 +76,13 @@ resource "binarylane_server_reverse_name" "test" { resource.TestCheckResourceAttrPair("binarylane_server_reverse_name.test", "ipv4_address", "binarylane_server.test", "public_ipv4_addresses.0"), resource.TestCheckResourceAttr("binarylane_server_reverse_name.test", "reverse_name", "tf-test-ptr.example.com"), resource.TestCheckResourceAttrSet("binarylane_server_reverse_name.test", "id"), + func(s *terraform.State) error { + attrs := s.RootModule().Resources["binarylane_server_reverse_name.test"].Primary.Attributes + var err error + serverId, err = strconv.ParseInt(attrs["server_id"], 10, 64) + ipv4Address = attrs["ipv4_address"] + return err + }, ), }, // ImportState testing @@ -89,7 +108,41 @@ resource "binarylane_server_reverse_name" "test" { resource.TestCheckResourceAttr("binarylane_server_reverse_name.test", "reverse_name", "tf-test-ptr-updated.example.com"), ), }, + // Removing the resource clears the reverse name while the server still exists + { + Config: providerConfig + serverConfig, + Check: func(_ *terraform.State) error { + return testAccCheckServerReverseNameCleared(serverId, ipv4Address) + }, + }, // Delete testing automatically occurs in TestCase }, }) } + +func testAccCheckServerReverseNameCleared(serverId int64, ipv4Address string) error { + client, err := binarylane.NewClientWithConfig("", "") + if err != nil { + return err + } + resp, err := client.GetServersServerIdWithResponse(context.Background(), serverId) + if err != nil { + return err + } + if resp.StatusCode() == http.StatusNotFound { + return nil + } + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("unexpected status %s reading server %d: %s", resp.Status(), serverId, resp.Body) + } + for _, n := range resp.JSON200.Server.Networks.V4 { + if n.IpAddress != ipv4Address || n.ReverseName == nil { + continue + } + switch *n.ReverseName { + case "tf-test-ptr.example.com", "tf-test-ptr-updated.example.com": + return fmt.Errorf("reverse name for %s on server %d not cleared: %s", ipv4Address, serverId, *n.ReverseName) + } + } + return nil +} diff --git a/internal/provider/server_reverse_name_resource_unit_test.go b/internal/provider/server_reverse_name_resource_unit_test.go new file mode 100644 index 0000000..5f5cb51 --- /dev/null +++ b/internal/provider/server_reverse_name_resource_unit_test.go @@ -0,0 +1,141 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "terraform-provider-binarylane/internal/binarylane" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerReverseNameResourceUnit(t *testing.T) { + ctx := context.Background() + + var ( + postStatus = http.StatusOK + serverFound = true + serverIp = "203.0.113.7" + serverName = "ptr.example.com" + lastBody map[string]any + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/servers/1/actions": + b, _ := io.ReadAll(r.Body) + lastBody = nil + if err := json.Unmarshal(b, &lastBody); err != nil { + t.Errorf("decode POST body: %v", err) + } + w.WriteHeader(postStatus) + if postStatus == http.StatusOK { + _, _ = w.Write([]byte(`{"action":{"id":9,"status":"in-progress"}}`)) + } + case r.Method == http.MethodGet && r.URL.Path == "/servers/1/actions/9": + _, _ = w.Write([]byte(`{"action":{"id":9,"status":"completed","completed_at":"2026-01-01T00:00:00Z"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/servers/1": + if !serverFound { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = fmt.Fprintf(w, `{"server":{"id":1,"networks":{"v4":[{"ip_address":%q,"type":"public","reverse_name":%q}]}}}`, serverIp, serverName) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + defer srv.Close() + + client, err := binarylane.NewClientWithConfig(srv.URL, "test") + if err != nil { + t.Fatal(err) + } + r := &serverReverseNameResource{bc: &BinarylaneClient{client: client}} + + schemaResp := resource.SchemaResponse{} + r.Schema(ctx, resource.SchemaRequest{}, &schemaResp) + s := schemaResp.Schema + emptyState := func() tfsdk.State { + return tfsdk.State{Schema: s, Raw: tftypes.NewValue(s.Type().TerraformType(ctx), nil)} + } + model := serverReverseNameResourceModel{ + Id: types.StringUnknown(), + ServerId: types.Int64Value(1), + Ipv4Address: types.StringValue("203.0.113.7"), + ReverseName: types.StringValue("ptr.example.com"), + } + plan := tfsdk.Plan{Schema: s, Raw: tftypes.NewValue(s.Type().TerraformType(ctx), nil)} + if diags := plan.Set(ctx, &model); diags.HasError() { + t.Fatal(diags) + } + + createResp := resource.CreateResponse{State: emptyState()} + r.Create(ctx, resource.CreateRequest{Plan: plan}, &createResp) + if createResp.Diagnostics.HasError() { + t.Fatal(createResp.Diagnostics) + } + if lastBody["reverse_name"] != "ptr.example.com" || lastBody["ipv4_address"] != "203.0.113.7" { + t.Fatalf("create: unexpected request body %v", lastBody) + } + state := createResp.State + + read := func() (serverReverseNameResourceModel, bool) { + t.Helper() + readResp := resource.ReadResponse{State: state} + r.Read(ctx, resource.ReadRequest{State: state}, &readResp) + if readResp.Diagnostics.HasError() { + t.Fatal(readResp.Diagnostics) + } + if readResp.State.Raw.IsNull() { + return serverReverseNameResourceModel{}, false + } + var got serverReverseNameResourceModel + if diags := readResp.State.Get(ctx, &got); diags.HasError() { + t.Fatal(diags) + } + return got, true + } + + serverName = "drifted.example.com" + if got, ok := read(); !ok || got.ReverseName.ValueString() != "drifted.example.com" { + t.Fatalf("read: expected drift to drifted.example.com, got %v (present=%v)", got.ReverseName, ok) + } + + serverIp = "198.51.100.1" + if _, ok := read(); ok { + t.Fatal("read: expected state removed when IP is not on the server") + } + + serverFound = false + if _, ok := read(); ok { + t.Fatal("read: expected state removed when server is 404") + } + + postStatus = http.StatusAccepted + updateResp := resource.UpdateResponse{State: emptyState()} + r.Update(ctx, resource.UpdateRequest{Plan: plan, State: state}, &updateResp) + if updateResp.Diagnostics.HasError() { + t.Fatalf("update: 202 without action should succeed: %v", updateResp.Diagnostics) + } + + deleteResp := resource.DeleteResponse{State: state} + r.Delete(ctx, resource.DeleteRequest{State: state}, &deleteResp) + if deleteResp.Diagnostics.HasError() { + t.Fatalf("delete: 202 without action should succeed: %v", deleteResp.Diagnostics) + } + if _, ok := lastBody["reverse_name"]; ok { + t.Fatalf("delete: request body must not contain reverse_name, got %v", lastBody) + } + if lastBody["ipv4_address"] != "203.0.113.7" { + t.Fatalf("delete: unexpected request body %v", lastBody) + } +}