Compare commits

..

2 Commits

Author SHA1 Message Date
Oscar Hermoso 1304891e24 🐛 Recover from other resources that no longer exist
Deleting a load balancer, SSH key or VPC that Binary Lane no longer has failed
with "Unexpected HTTP status code deleting ...", leaving it in state with
nothing left to delete. Every delete now treats a 404 as the success it
describes. Firewall rules and route entries are cleared through their parent, so
a 404 there is the server or VPC having gone away.

SSH keys and server firewall rules are also removed from state on a 404 during
read, which #71 gave the other resources but not those two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:26:10 +08:00
Oscar Hermoso 30823b573c 🐛 Recover from a server that no longer exists
Fixes #69, which regressed when the read was extracted into
fetchServerResourceState: it reports diagnostics, and a diagnostic can only fail
the read, so the 404 handling added in #71 was lost. A server deleted outside of
Terraform failed every plan until the state was edited by hand.

fetchServerResourceState now returns an error, and errServerNotFound for a 404,
which Read turns back into removing the resource from state. Create and Update
treat it like any other read failure, since the server is meant to exist there.
Delete treats a 404 as the success it describes, for the paths that reach it
without a refresh first: `-refresh=false`, or a server deleted in between.

Also fixes the advanced features conversion overwriting the diagnostics gathered
before it.

TestServerResourceDeletedOutOfBand covers the read: it deletes the server
through the API and refreshes, which fails against the code before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:26:10 +08:00
9 changed files with 166 additions and 25 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
args: release --clean
env:
@@ -384,6 +384,11 @@ func (r *loadBalancerResource) Delete(ctx context.Context, req resource.DeleteRe
return
}
if lbResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("Load balancer already gone, nothing to delete: id=%s, name=%s", data.Id.String(), data.Name.ValueString()))
return
}
if lbResp.StatusCode() != http.StatusNoContent {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting load balancer",
@@ -145,6 +145,12 @@ func (r *serverFirewallRulesResource) Read(ctx context.Context, req resource.Rea
)
return
}
if fwResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("Server firewall rules not found, removing from state: server_id=%d", data.ServerId.ValueInt64()))
resp.State.RemoveResource(ctx)
return
}
if fwResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code reading server firewall rules",
@@ -237,6 +243,11 @@ func (r *serverFirewallRulesResource) Delete(ctx context.Context, req resource.D
)
return
}
if serverResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("Server already gone, no firewall rules to delete: server_id=%s", data.ServerId.String()))
return
}
if serverResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting server firewall rules",
+48 -24
View File
@@ -3,6 +3,7 @@ package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
@@ -772,8 +773,9 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
// One extra read to check the final state of enabled_advanced_features, needed because
// some flags (like "cloud-init") are not set until the server is fully created. See #13
diag := r.fetchServerResourceState(ctx, &data)
resp.Diagnostics.Append(diag...)
if err := r.fetchServerResourceState(ctx, &data); err != nil {
resp.Diagnostics.AddError("Error reading server", err.Error())
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
@@ -791,8 +793,15 @@ func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, res
// Read API call logic
tflog.Debug(ctx, fmt.Sprintf("Reading server: id=%s, name=%s", data.Id.String(), data.Name.ValueString()))
diag := r.fetchServerResourceState(ctx, &data)
resp.Diagnostics.Append(diag...)
if err := r.fetchServerResourceState(ctx, &data); err != nil {
if errors.Is(err, errServerNotFound) {
tflog.Warn(ctx, fmt.Sprintf("Server not found, removing from state: id=%s, name=%s", data.Id.String(), data.Name.ValueString()))
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Error reading server", err.Error())
return
}
// Get user data script
userDataResp, err := r.bc.client.GetServersServerIdUserDataWithResponse(ctx, data.Id.ValueInt64())
@@ -841,8 +850,10 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
return
}
diag := r.fetchServerResourceState(ctx, &state)
resp.Diagnostics.Append(diag...)
if err := r.fetchServerResourceState(ctx, &state); err != nil {
resp.Diagnostics.AddError("Error reading server", err.Error())
return
}
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
@@ -915,8 +926,10 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
if !plan.VpcIpv4Address.Equal(state.VpcIpv4Address) && !plan.VpcIpv4Address.IsNull() && !plan.VpcIpv4Address.IsUnknown() {
// If VPC was just changed, IP address needs to be fetched so it can be sent in the request
if refreshNeeded {
diag := r.fetchServerResourceState(ctx, &state)
resp.Diagnostics.Append(diag...)
if err := r.fetchServerResourceState(ctx, &state); err != nil {
resp.Diagnostics.AddError("Error reading server", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
@@ -1306,6 +1319,11 @@ func (r *serverResource) Delete(ctx context.Context, req resource.DeleteRequest,
return
}
if serverResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("Server already gone, nothing to delete: id=%s, name=%s", data.Id.String(), data.Name.ValueString()))
return
}
if serverResp.StatusCode() != http.StatusNoContent {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting server",
@@ -1473,25 +1491,25 @@ func (r *serverResource) updateSeparatePrivateNetworkInterface(
return nil
}
func (r *serverResource) fetchServerResourceState(ctx context.Context, state *serverResourceModel) diag.Diagnostics {
// errServerNotFound reports that Binary Lane no longer has the server, so that Read can drop it
// from state instead of failing (see #69).
var errServerNotFound = errors.New("server not found")
// fetchServerResourceState reads the server and maps it into state, returning errServerNotFound
// if the server no longer exists.
func (r *serverResource) fetchServerResourceState(ctx context.Context, state *serverResourceModel) error {
var diags diag.Diagnostics
serverResp, err := r.bc.client.GetServersServerIdWithResponse(ctx, state.Id.ValueInt64())
if err != nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic(
fmt.Sprintf("Error reading server: id=%s, name=%s", state.Id.String(), state.Name.ValueString()),
err.Error(),
),
}
return fmt.Errorf("error reading server: id=%s, name=%s, error: %w", state.Id, state.Name.ValueString(), err)
}
if serverResp.StatusCode() == http.StatusNotFound {
return errServerNotFound
}
if serverResp.StatusCode() != http.StatusOK {
return diag.Diagnostics{
diag.NewErrorDiagnostic(
fmt.Sprintf("Unexpected HTTP status code %s reading server: name=%s, id=%s", serverResp.Status(), state.Name.ValueString(), state.Id.String()),
string(serverResp.Body),
),
}
return fmt.Errorf("unexpected HTTP status code %s reading server: id=%s, name=%s, details: %s",
serverResp.Status(), state.Id, state.Name.ValueString(), serverResp.Body)
}
state.Id = types.Int64Value(serverResp.JSON200.Server.Id)
@@ -1524,7 +1542,7 @@ func (r *serverResource) fetchServerResourceState(ctx context.Context, state *se
}
advFeat := serverResp.JSON200.Server.AdvancedFeatures.EnabledAdvancedFeatures
state.AdvancedFeatures, diags = resources.NewAdvancedFeaturesValue(
advFeatValue, advFeatDiags := resources.NewAdvancedFeaturesValue(
resources.AdvancedFeaturesValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"emulated_hyperv": types.BoolValue(slices.Contains(advFeat, "emulated-hyperv")),
@@ -1538,7 +1556,9 @@ func (r *serverResource) fetchServerResourceState(ctx context.Context, state *se
"qemu_guest_agent": types.BoolValue(slices.Contains(advFeat, "qemu-guest-agent")),
"uefi_boot": types.BoolValue(slices.Contains(advFeat, "uefi-boot")),
})
if diags.HasError() {
diags.Append(advFeatDiags...)
state.AdvancedFeatures = advFeatValue
if advFeatDiags.HasError() {
state.AdvancedFeatures = resources.NewAdvancedFeaturesValueUnknown()
}
@@ -1600,7 +1620,11 @@ func (r *serverResource) fetchServerResourceState(ctx context.Context, state *se
state.PrivateIpv6Addresses = tfPrivateIpv6Addresses
}
return diags
if diags.HasError() {
return errFromDiagnostics(diags)
}
return nil
}
func isAdvFeatChanged(config *resources.AdvancedFeaturesValue, data *resources.AdvancedFeaturesValue) bool {
+67
View File
@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"regexp"
"strconv"
"strings"
"terraform-provider-binarylane/internal/binarylane"
"testing"
@@ -284,6 +285,72 @@ EOT
})
}
// TestServerResourceDeletedOutOfBand covers a server that no longer exists at Binary Lane: the
// next refresh drops it from state so a plan can re-create it, rather than failing with the 404
// and leaving the state to be edited by hand (#69).
func TestServerResourceDeletedOutOfBand(t *testing.T) {
// Must assign a password to the server or Binary Lane will send emails
password := GenerateTestPassword(t)
var serverId string
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-out-of-band"
// TEMPORARY: "per" is intermittently returning "Unable to find a suitable host for the
// requested server configuration"
region = "mel"
image = "debian-12"
size = "std-min"
public_ipv4_count = 0
password = "` + password + `"
}
`,
Check: resource.TestCheckResourceAttrWith("binarylane_server.test", "id", func(value string) error {
serverId = value
return nil
}),
},
{
PreConfig: func() { deleteServerOutOfBand(t, serverId) },
RefreshState: true,
ExpectNonEmptyPlan: true, // the server is gone from state, so the plan re-creates it
},
},
})
}
// deleteServerOutOfBand deletes a server through the API, as if someone had removed it outside of
// Terraform.
func deleteServerOutOfBand(t *testing.T, serverId string) {
t.Helper()
client, err := binarylane.NewClientWithDefaultConfig()
if err != nil {
t.Fatalf("Error creating Binary Lane API client: %s", err)
}
id, err := strconv.ParseInt(serverId, 10, 64)
if err != nil {
t.Fatalf("Error parsing server id %q: %s", serverId, err)
}
reason := "Terraform deletion"
deleteResp, err := client.DeleteServersServerIdWithResponse(context.Background(), id, &binarylane.DeleteServersServerIdParams{
Reason: &reason,
})
if err != nil {
t.Fatalf("Error deleting server %d out of band: %s", id, err)
}
if deleteResp.StatusCode() != http.StatusNoContent {
t.Fatalf("Unexpected status %d deleting server %d out of band: %s", deleteResp.StatusCode(), id, deleteResp.Body)
}
}
func TestServerResourceRename(t *testing.T) {
// Must assign a password to the server or Binary Lane will send emails
password := GenerateTestPassword(t)
+11
View File
@@ -182,6 +182,12 @@ func (r *sshKeyResource) Read(ctx context.Context, req resource.ReadRequest, res
)
return
}
if sshResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("SSH Key not found, removing from state: id=%s, name=%s", data.Id.String(), data.Name.ValueString()))
resp.State.RemoveResource(ctx)
return
}
if sshResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code getting SSH key",
@@ -264,6 +270,11 @@ func (r *sshKeyResource) Delete(ctx context.Context, req resource.DeleteRequest,
)
return
}
if sshResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("SSH Key already gone, nothing to delete: id=%s, name=%s", state.Id.String(), state.Name.ValueString()))
return
}
if sshResp.StatusCode() != http.StatusNoContent {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting SSH Key",
+13
View File
@@ -2,11 +2,14 @@ package provider
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/hashicorp/terraform-plugin-framework/attr"
d_schema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/diag"
r_schema "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
@@ -266,3 +269,13 @@ func listContainsUnknown(ctx context.Context, list types.List) bool {
func Pointer[T any](d T) *T {
return &d
}
// errFromDiagnostics turns error diagnostics into an error, for helpers that report errors rather
// than diagnostics.
func errFromDiagnostics(diags diag.Diagnostics) error {
messages := make([]string, 0, len(diags))
for _, d := range diags.Errors() {
messages = append(messages, fmt.Sprintf("%s: %s", d.Summary(), d.Detail()))
}
return errors.New(strings.Join(messages, "; "))
}
+5
View File
@@ -230,6 +230,11 @@ func (r *vpcResource) Delete(ctx context.Context, req resource.DeleteRequest, re
return
}
if vpcResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("VPC already gone, nothing to delete: id=%d", data.Id.ValueInt64()))
return
}
if vpcResp.StatusCode() != http.StatusNoContent {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting VPC",
@@ -217,6 +217,11 @@ func (r *vpcRouteEntriesResource) Delete(ctx context.Context, req resource.Delet
return
}
if vpcResp.StatusCode() == http.StatusNotFound {
tflog.Warn(ctx, fmt.Sprintf("VPC already gone, no route entries to delete: vpc_id=%d", data.VpcId.ValueInt64()))
return
}
if vpcResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code deleting VPC route entries",