diff --git a/docs/resources/server_reverse_name.md b/docs/resources/server_reverse_name.md new file mode 100644 index 0000000..1997228 --- /dev/null +++ b/docs/resources/server_reverse_name.md @@ -0,0 +1,48 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "binarylane_server_reverse_name Resource - terraform-provider-binarylane" +subcategory: "" +description: |- + Manage the reverse name (PTR record) for a public IPv4 address of a BinaryLane server. Destroying this resource clears the custom reverse name, restoring the BinaryLane default. +--- + +# binarylane_server_reverse_name (Resource) + +Manage the reverse name (PTR record) for a public IPv4 address of a BinaryLane server. Destroying this resource clears the custom reverse name, restoring the BinaryLane default. + +## Example Usage + +```terraform +resource "binarylane_server" "example" { + # ... +} + +resource "binarylane_server_reverse_name" "example" { + server_id = binarylane_server.example.id + ipv4_address = binarylane_server.example.public_ipv4_addresses.0 + reverse_name = "mx1.example.com" +} +``` + + +## Schema + +### Required + +- `ipv4_address` (String) The public IPv4 address to set the reverse name for. +- `reverse_name` (String) The reverse name (PTR record) for the IPv4 address. +- `server_id` (Number) The ID of the server the IPv4 address is assigned to. + +### Read-Only + +- `id` (String) The ID of this resource, in the format `:`. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import binarylane_server_reverse_name.example ":" +``` diff --git a/examples/resources/binarylane_server_reverse_name/import.sh b/examples/resources/binarylane_server_reverse_name/import.sh new file mode 100644 index 0000000..0e2cae1 --- /dev/null +++ b/examples/resources/binarylane_server_reverse_name/import.sh @@ -0,0 +1 @@ +terraform import binarylane_server_reverse_name.example ":" diff --git a/examples/resources/binarylane_server_reverse_name/resource.tf b/examples/resources/binarylane_server_reverse_name/resource.tf new file mode 100644 index 0000000..dffd465 --- /dev/null +++ b/examples/resources/binarylane_server_reverse_name/resource.tf @@ -0,0 +1,9 @@ +resource "binarylane_server" "example" { + # ... +} + +resource "binarylane_server_reverse_name" "example" { + server_id = binarylane_server.example.id + ipv4_address = binarylane_server.example.public_ipv4_addresses.0 + reverse_name = "mx1.example.com" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d8da0f9..e81e49a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -103,6 +103,7 @@ func (p *binarylaneProvider) Resources(_ context.Context) []func() resource.Reso return []func() resource.Resource{ NewServerResource, NewServerFirewallRulesResource, + NewServerReverseNameResource, NewSshKeyResource, NewVpcResource, NewVpcRouteEntriesResource, diff --git a/internal/provider/server_reverse_name_resource.go b/internal/provider/server_reverse_name_resource.go new file mode 100644 index 0000000..ff792f1 --- /dev/null +++ b/internal/provider/server_reverse_name_resource.go @@ -0,0 +1,296 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "net/netip" + "strconv" + "strings" + "terraform-provider-binarylane/internal/binarylane" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = &serverReverseNameResource{} + _ resource.ResourceWithConfigure = &serverReverseNameResource{} + _ resource.ResourceWithImportState = &serverReverseNameResource{} +) + +func NewServerReverseNameResource() resource.Resource { + return &serverReverseNameResource{} +} + +type serverReverseNameResource struct { + bc *BinarylaneClient +} + +type serverReverseNameResourceModel struct { + Id types.String `tfsdk:"id"` + ServerId types.Int64 `tfsdk:"server_id"` + Ipv4Address types.String `tfsdk:"ipv4_address"` + ReverseName types.String `tfsdk:"reverse_name"` +} + +func (r *serverReverseNameResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + bc, ok := req.ProviderData.(BinarylaneClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *BinarylaneClient, got: %T.", req.ProviderData), + ) + return + } + r.bc = &bc +} + +func (r *serverReverseNameResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server_reverse_name" +} + +func (r *serverReverseNameResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manage the reverse name (PTR record) for a public IPv4 address of a BinaryLane server. " + + "Destroying this resource clears the custom reverse name, restoring the BinaryLane default.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of this resource, in the format `:`.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "server_id": schema.Int64Attribute{ + Description: "The ID of the server the IPv4 address is assigned to.", + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "ipv4_address": schema.StringAttribute{ + Description: "The public IPv4 address to set the reverse name for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "reverse_name": schema.StringAttribute{ + Description: "The reverse name (PTR record) for the IPv4 address.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + }, + } +} + +func (r *serverReverseNameResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data serverReverseNameResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Create API call logic + tflog.Debug(ctx, fmt.Sprintf("Creating server reverse name: server_id=%d, ipv4_address=%s", + data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + err := r.changeReverseName(ctx, data.ServerId.ValueInt64(), data.Ipv4Address.ValueString(), data.ReverseName.ValueStringPointer()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error creating server reverse name: server_id=%d, ipv4_address=%s", data.ServerId.ValueInt64(), data.Ipv4Address.ValueString()), + err.Error(), + ) + return + } + + data.Id = types.StringValue(formatServerReverseNameId(data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *serverReverseNameResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data serverReverseNameResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Read API call logic + tflog.Debug(ctx, fmt.Sprintf("Reading server reverse name: server_id=%d, ipv4_address=%s", + data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + serverResp, err := r.bc.client.GetServersServerIdWithResponse(ctx, data.ServerId.ValueInt64()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error reading server reverse name: server_id=%d", data.ServerId.ValueInt64()), + err.Error(), + ) + return + } + if serverResp.StatusCode() == http.StatusNotFound { + tflog.Warn(ctx, fmt.Sprintf("Server not found, removing reverse name from state: server_id=%d", data.ServerId.ValueInt64())) + resp.State.RemoveResource(ctx) + return + } + if serverResp.StatusCode() != http.StatusOK { + resp.Diagnostics.AddError( + "Unexpected HTTP status code reading server reverse name", + fmt.Sprintf("Received %s reading server reverse name: server_id=%d. Details: %s", serverResp.Status(), data.ServerId.ValueInt64(), serverResp.Body)) + return + } + + var network *binarylane.Network + for i, n := range serverResp.JSON200.Server.Networks.V4 { + if n.IpAddress == data.Ipv4Address.ValueString() { + network = &serverResp.JSON200.Server.Networks.V4[i] + break + } + } + if network == nil { + tflog.Warn(ctx, fmt.Sprintf("IPv4 address not found on server, removing reverse name from state: server_id=%d, ipv4_address=%s", + data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + resp.State.RemoveResource(ctx) + return + } + + data.Id = types.StringValue(formatServerReverseNameId(data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + data.ReverseName = types.StringPointerValue(network.ReverseName) + + // Save into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *serverReverseNameResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data serverReverseNameResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update API call logic + tflog.Debug(ctx, fmt.Sprintf("Updating server reverse name: server_id=%d, ipv4_address=%s", + data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + err := r.changeReverseName(ctx, data.ServerId.ValueInt64(), data.Ipv4Address.ValueString(), data.ReverseName.ValueStringPointer()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error updating server reverse name: server_id=%d, ipv4_address=%s", data.ServerId.ValueInt64(), data.Ipv4Address.ValueString()), + err.Error(), + ) + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *serverReverseNameResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data serverReverseNameResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Delete API call logic: a null reverse name clears the custom reverse name + tflog.Debug(ctx, fmt.Sprintf("Deleting server reverse name: server_id=%d, ipv4_address=%s", + data.ServerId.ValueInt64(), data.Ipv4Address.ValueString())) + err := r.changeReverseName(ctx, data.ServerId.ValueInt64(), data.Ipv4Address.ValueString(), nil) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error deleting server reverse name: server_id=%d, ipv4_address=%s", data.ServerId.ValueInt64(), data.Ipv4Address.ValueString()), + err.Error(), + ) + return + } +} + +func (r *serverReverseNameResource) ImportState( + ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse, +) { + serverId, ipv4Address, err := parseServerReverseNameId(req.ID) + if err != nil { + resp.Diagnostics.AddError( + "Error importing server reverse name", + "Could not import server reverse name, unexpected error (ID should be :): "+err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("ipv4_address"), ipv4Address)...) +} + +func (r *serverReverseNameResource) changeReverseName(ctx context.Context, serverId int64, ipv4Address string, reverseName *string) error { + actionResp, err := r.bc.client.PostServersServerIdActionsChangeReverseNameWithResponse( + ctx, + serverId, + binarylane.PostServersServerIdActionsChangeReverseNameJSONRequestBody{ + Type: "change_reverse_name", + Ipv4Address: ipv4Address, + ReverseName: reverseName, + }, + ) + if err != nil { + return fmt.Errorf("error changing reverse name for server: server_id=%d, error: %w", serverId, err) + } + 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 { + return fmt.Errorf("error changing reverse name: %w", err) + } + + return nil +} + +func formatServerReverseNameId(serverId int64, ipv4Address string) string { + return fmt.Sprintf("%d:%s", serverId, ipv4Address) +} + +func parseServerReverseNameId(id string) (int64, string, error) { + serverIdStr, ipv4Address, ok := strings.Cut(id, ":") + if !ok { + return 0, "", fmt.Errorf("missing ':' separator in %q", id) + } + serverId, err := strconv.ParseInt(serverIdStr, 10, 64) + if err != nil || serverId < 1 { + return 0, "", fmt.Errorf("invalid server_id %q", serverIdStr) + } + addr, err := netip.ParseAddr(ipv4Address) + if err != nil || !addr.Is4() { + return 0, "", fmt.Errorf("invalid ipv4_address %q", ipv4Address) + } + return serverId, ipv4Address, nil +} diff --git a/internal/provider/server_reverse_name_resource_test.go b/internal/provider/server_reverse_name_resource_test.go new file mode 100644 index 0000000..1c6d615 --- /dev/null +++ b/internal/provider/server_reverse_name_resource_test.go @@ -0,0 +1,148 @@ +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" +) + +func TestParseServerReverseNameId(t *testing.T) { + serverId, ip, err := parseServerReverseNameId(formatServerReverseNameId(12345, "203.0.113.7")) + if err != nil || serverId != 12345 || ip != "203.0.113.7" { + t.Fatalf("round trip: got (%d, %q, %v)", serverId, ip, err) + } + + for _, id := range []string{ + "", + "12345", + "12345:", + ":203.0.113.7", + "abc:203.0.113.7", + "0:203.0.113.7", + "-1:203.0.113.7", + "12345:not-an-ip", + "12345:2001:db8::1", + "12345:203.0.113.7:80", + } { + if _, _, err := parseServerReverseNameId(id); err == nil { + t.Errorf("expected error parsing %q", id) + } + } +} + +func TestServerReverseNameResource(t *testing.T) { + // Must assign a password to the server or Binary Lane will send emails + password := GenerateTestPassword(t) + + serverConfig := ` + +resource "binarylane_server" "test" { + name = "tf-test-server-reverse-name" + region = "per" + image = "debian-12" + size = "std-min" + password = "` + password + `" + public_ipv4_count = 1 +} +` + + 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 + { + Config: providerConfig + serverConfig + ` +resource "binarylane_server_reverse_name" "test" { + server_id = binarylane_server.test.id + ipv4_address = binarylane_server.test.public_ipv4_addresses.0 + reverse_name = "tf-test-ptr.example.com" +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("binarylane_server_reverse_name.test", "server_id", "binarylane_server.test", "id"), + 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 + { + ResourceName: "binarylane_server_reverse_name.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + resourceState := s.RootModule().Resources["binarylane_server_reverse_name.test"] + return resourceState.Primary.Attributes["id"], nil + }, + }, + // Update and Read testing + { + Config: providerConfig + serverConfig + ` +resource "binarylane_server_reverse_name" "test" { + server_id = binarylane_server.test.id + ipv4_address = binarylane_server.test.public_ipv4_addresses.0 + reverse_name = "tf-test-ptr-updated.example.com" # Updated reverse name +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + 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) + } +}