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
34 changed files with 177 additions and 931 deletions
-1
View File
@@ -30,4 +30,3 @@ tmp
# Normally, you wouldn't want to exclude lock files, but our examples are tested
# with a locally built provider and won't be useful to others.
.terraform.lock.hcl
*.zip
-17
View File
@@ -1,17 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^docs/
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
exclude: ^tools/
- id: go-mod-tidy
-30
View File
@@ -1,30 +0,0 @@
when:
- event: pull_request
steps:
- name: build
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-terraform-provider-binarylane
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
-30
View File
@@ -1,30 +0,0 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-terraform-provider-binarylane
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
-52
View File
@@ -1,52 +0,0 @@
when:
- event: tag
steps:
- name: package
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-terraform-provider-binarylane
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands:
- make package VERSION=${CI_COMMIT_TAG}
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: upload
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
VERSION=$$(echo ${CI_COMMIT_TAG} | sed 's/^v//')
FILE="terraform-provider-binarylane_$${VERSION}_linux_amd64.zip"
curl -f -X PUT \
"https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/terraform-unkin/files/unkin/binarylane/$${FILE}" \
-H "Content-Type: application/zip" \
--data-binary @"$${FILE}"
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
-57
View File
@@ -1,57 +0,0 @@
when:
- event: pull_request
steps:
- name: lint
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-terraform-provider-binarylane
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-terraform-provider-binarylane
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands:
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+1 -1
View File
@@ -14,7 +14,7 @@ Based on [this example from the terraform docs](https://developer.hashicorp.com/
provider_installation {
dev_overrides {
# Example GOBIN path, will need to be replaced with your own GOBIN path. Default is $GOPATH/bin
"git.unkin.net/unkin/binarylane" = "/path/to/terraform-provider-binarylane/bin"
"oscarhermoso/binarylane" = "/home/oscarhermoso/Git/terraform-provider-binarylane/bin"
}
direct {}
-64
View File
@@ -1,64 +0,0 @@
.PHONY: build install test lint fmt clean tidy package pre-commit patch minor major check-go
BINARY := terraform-provider-binarylane
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
OS_ARCH := linux_amd64
INSTALL_VERSION := $(shell echo $(VERSION) | sed 's/^v//')
INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/binarylane/$(INSTALL_VERSION)/$(OS_ARCH)
ZIP := $(BINARY)_$(INSTALL_VERSION)_$(OS_ARCH).zip
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)
install: build
mkdir -p $(INSTALL_DIR)
cp $(BINARY) $(INSTALL_DIR)/
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
package: build
cp $(BINARY) $(BINARY)_v$(INSTALL_VERSION)
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); z.write(sys.argv[2]); z.close()" $(ZIP) $(BINARY)_v$(INSTALL_VERSION)
rm $(BINARY)_v$(INSTALL_VERSION)
pre-commit:
uvx pre-commit run --all-files
clean:
rm -f $(BINARY) *.zip
tidy:
go mod tidy
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+1 -1
View File
@@ -15,7 +15,7 @@ description: |-
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
-48
View File
@@ -1,48 +0,0 @@
---
# 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 generated by tfplugindocs -->
## 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 `<server_id>:<ipv4_address>`.
## 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 "<server id>:<ipv4 address>"
```
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ If you want to deploy multiple parts of a cloud-init configuration, you can use
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
@@ -1 +0,0 @@
terraform import binarylane_server_reverse_name.example "<server id>:<ipv4 address>"
@@ -1,9 +0,0 @@
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"
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "git.unkin.net/unkin/binarylane"
source = "oscarhermoso/binarylane"
}
}
}
@@ -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",
-1
View File
@@ -103,7 +103,6 @@ func (p *binarylaneProvider) Resources(_ context.Context) []func() resource.Reso
return []func() resource.Resource{
NewServerResource,
NewServerFirewallRulesResource,
NewServerReverseNameResource,
NewSshKeyResource,
NewVpcResource,
NewVpcRouteEntriesResource,
@@ -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)
@@ -1,296 +0,0 @@
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 `<server_id>:<ipv4_address>`.",
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 <server_id>:<ipv4_address>): "+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
}
@@ -1,148 +0,0 @@
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
}
@@ -1,141 +0,0 @@
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)
}
}
+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",
+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
flag.Parse()
opts := providerserver.ServeOpts{
Address: "git.unkin.net/unkin/binarylane",
Address: "hashicorp.com/oscarhermoso/binarylane",
Debug: debug,
}