Compare commits

..

9 Commits

Author SHA1 Message Date
benvin 093c2e022a Merge pull request 'Fix binarylane_server_reverse_name create hanging forever' (#4) from benvin/reverse-name-wait-fix into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #4
2026-10-17 12:29:33 +11:00
unkin-agent a9c133e73c Wait for reverse names on the server and bound action waits
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-16 23:51:11 +11:00
benvin f659cfcd25 Merge pull request 'Allow server firewall rules without destination_ports' (#3) from benvin/firewall-optional-ports into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #3
2026-10-12 21:18:30 +11:00
benvin 3ed14bdae3 Merge pull request 'Add binarylane_server_reverse_name resource' (#2) from benvin/reverse-name into main
Reviewed-on: #2
2026-10-12 21:17:57 +11:00
benvin 2e9f10dc46 Merge pull request 'Add estate CI, release and git.unkin.net source address' (#1) from benvin/estate-ci into main
Reviewed-on: #1
2026-10-12 21:16:55 +11:00
unkin-agent 0876e38dea Accept 202 for reverse name changes and add unit and destroy checks
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-12 00:51:39 +11:00
unkin-agent 00b6fae313 Allow server firewall rules without destination_ports
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-12 00:46:52 +11:00
unkin-agent f42c218d98 Add binarylane_server_reverse_name resource for IPv4 PTR records
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-12 00:46:36 +11:00
unkin-agent d2451eb650 Add estate CI, release and Makefile; use git.unkin.net/unkin/binarylane source
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-12 00:40:26 +11:00
48 changed files with 1348 additions and 2033 deletions
+1
View File
@@ -30,3 +30,4 @@ 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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,57 @@
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
"oscarhermoso/binarylane" = "/home/oscarhermoso/Git/terraform-provider-binarylane/bin"
"git.unkin.net/unkin/binarylane" = "/path/to/terraform-provider-binarylane/bin"
}
direct {}
+64
View File
@@ -0,0 +1,64 @@
.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 -13
View File
@@ -29,11 +29,10 @@ data "binarylane_server" "example" {
- `advanced_features` (Object) (see [below for nested schema](#nestedatt--advanced_features))
- `backups` (Boolean) If `true` this will enable two daily backups for the server. By default, backups are disabled.
- `disk` (Number) The total storage in GB for this server. Leave null to accept the default for the size. Valid values:
- `disk` (Number) The total storage in GB for this server. Leave null to accept the default for the size Valid values:
- must be a multiple of 5
- \> 60 GB must be a multiple of 10
- \> 200 GB must be a multiple of 100
- `disks` (Attributes List) The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane. (see [below for nested schema](#nestedatt--disks))
- `image` (String) The slug of the selected operating system, such as `debian-12`. You can fetch a full list of images from the BinaryLane API.
- `ipv6` (Boolean) If `true` this will add a public and private IPv6 address to the server. By default, IPv6 is disabled.
- `memory` (Number) The total memory in MB for this server. Leave null to accept the default size. Valid values:
@@ -72,14 +71,3 @@ Read-Only:
- `qemu_guest_agent` (Boolean)
- `uefi_boot` (Boolean)
- `unset_uuid` (Boolean)
<a id="nestedatt--disks"></a>
### Nested Schema for `disks`
Read-Only:
- `description` (String) A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.
- `id` (Number) The ID of this disk.
- `primary` (Boolean) If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary.
- `size_gigabytes` (Number) The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.
+1 -1
View File
@@ -15,7 +15,7 @@ description: |-
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -27
View File
@@ -20,14 +20,6 @@ resource "binarylane_server" "example" {
size = "std-min" # 1 VPCU, 1 GB Memory, 20 GB NVME Storage, 1000 GB Data Transfer
public_ipv4_count = 1
# To manage multiple disks, every disk must be listed, including the primary
# disk = 40
# disks = [
# { primary = true, size_gigabytes = 20 },
# { description = "data1", size_gigabytes = 10 },
# { description = "data2", size_gigabytes = 10 },
# ]
# Accepts a cloud-init script or cloud-config YAML file to configure the server
# See more: https://cloudinit.readthedocs.io/en/latest/explanation/format.html#user-data-script
user_data = file("./init.sh")
@@ -48,11 +40,10 @@ resource "binarylane_server" "example" {
- `advanced_features` (Attributes) (see [below for nested schema](#nestedatt--advanced_features))
- `backups` (Boolean) If `true` this will enable two daily backups for the server. By default, backups are disabled.
- `disk` (Number) The total storage in GB for this server. Leave null to accept the default for the size. Valid values:
- `disk` (Number) The total storage in GB for this server. Leave null to accept the default for the size Valid values:
- must be a multiple of 5
- \> 60 GB must be a multiple of 10
- \> 200 GB must be a multiple of 100
- `disks` (Attributes List) The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane. (see [below for nested schema](#nestedatt--disks))
- `ipv6` (Boolean) If `true` this will add a public and private IPv6 address to the server. By default, IPv6 is disabled.
- `memory` (Number) The total memory in MB for this server. Leave null to accept the default size. Valid values:
- must be a multiple of 128
@@ -100,23 +91,6 @@ Read-Only:
- `uefi_boot` (Boolean) When this option is enabled the Cloud Server will use UEFI instead of legacy PC BIOS.
<a id="nestedatt--disks"></a>
### Nested Schema for `disks`
Required:
- `size_gigabytes` (Number) The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.
Optional:
- `description` (String) A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.
- `primary` (Boolean) If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary.
Read-Only:
- `id` (Number) The ID of this disk.
<a id="nestedatt--timeouts"></a>
### Nested Schema for `timeouts`
+48
View File
@@ -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 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 = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/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 = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
@@ -5,14 +5,6 @@ resource "binarylane_server" "example" {
size = "std-min" # 1 VPCU, 1 GB Memory, 20 GB NVME Storage, 1000 GB Data Transfer
public_ipv4_count = 1
# To manage multiple disks, every disk must be listed, including the primary
# disk = 40
# disks = [
# { primary = true, size_gigabytes = 20 },
# { description = "data1", size_gigabytes = 10 },
# { description = "data2", size_gigabytes = 10 },
# ]
# Accepts a cloud-init script or cloud-config YAML file to configure the server
# See more: https://cloudinit.readthedocs.io/en/latest/explanation/format.html#user-data-script
user_data = file("./init.sh")
@@ -0,0 +1 @@
terraform import binarylane_server_reverse_name.example "<server id>:<ipv4 address>"
@@ -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"
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
terraform {
required_providers {
binarylane = {
source = "oscarhermoso/binarylane"
source = "git.unkin.net/unkin/binarylane"
}
}
}
-16
View File
@@ -4106,22 +4106,6 @@ func NewGetLoadBalancersRequest(server string, params *GetLoadBalancersParams) (
if params != nil {
queryValues := queryURL.Query()
if params.Name != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
return nil, err
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
return nil, err
} else {
for k, v := range parsed {
for _, v2 := range v {
queryValues.Add(k, v2)
}
}
}
}
if params.Page != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: "int32"}); err != nil {
+26 -29
View File
@@ -2164,16 +2164,16 @@ type DisableSelinuxType string
// Disk defines model for Disk.
type Disk struct {
// Description A description of this disk.
Description *string `json:"description,omitempty" tfsdk:"description"`
Description *string `json:"description,omitempty"`
// Id The ID of this disk.
Id int64 `json:"id" tfsdk:"id"`
Id int64 `json:"id"`
// Primary A primary disk is treated differently from other disks.
Primary bool `json:"primary" tfsdk:"primary"`
Primary bool `json:"primary"`
// SizeGigabytes The size of the disk in GB.
SizeGigabytes float64 `json:"size_gigabytes" tfsdk:"size_gigabytes"`
SizeGigabytes float64 `json:"size_gigabytes"`
}
// DistributionFeature
@@ -4093,7 +4093,7 @@ type GetAccountKeysParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4108,7 +4108,7 @@ type GetActionsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4120,7 +4120,7 @@ type GetCustomersMyInvoicesParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4129,7 +4129,7 @@ type GetDataUsagesCurrentParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4138,7 +4138,7 @@ type GetDomainsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4183,7 +4183,7 @@ type GetDomainsDomainNameRecordsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4234,7 +4234,7 @@ type GetImagesParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4246,13 +4246,10 @@ type GetImagesImageIdOrSlugParamsImageIdOrSlug1 = string
// GetLoadBalancersParams defines parameters for GetLoadBalancers.
type GetLoadBalancersParams struct {
// Name Providing a name restricts the results to load balancers with this name (case insensitive).
Name *string `form:"name,omitempty" json:"name,omitempty"`
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4279,7 +4276,7 @@ type GetRegionsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4288,7 +4285,7 @@ type GetReverseNamesIpv6Params struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4318,7 +4315,7 @@ type GetSamplesetsServerIdParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4345,7 +4342,7 @@ type GetServersParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4363,7 +4360,7 @@ type GetServersServerIdActionsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4375,7 +4372,7 @@ type GetServersServerIdBackupsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4387,7 +4384,7 @@ type GetServersServerIdKernelsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4396,7 +4393,7 @@ type GetServersServerIdSnapshotsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4405,7 +4402,7 @@ type GetServersServerIdSoftwareParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4420,7 +4417,7 @@ type GetSizesParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4429,7 +4426,7 @@ type GetSoftwareParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4438,7 +4435,7 @@ type GetSoftwareOperatingSystemOperatingSystemIdOrSlugParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4453,7 +4450,7 @@ type GetVpcsParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4483,7 +4480,7 @@ type GetVpcsVpcIdMembersParams struct {
// Page The selected page. Page numbering starts at 1
Page *int32 `form:"page,omitempty" json:"page,omitempty"`
// PerPage The number of results to show per page. Use 0 to return only the total count.
// PerPage The number of results to show per page.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
+42 -27
View File
@@ -140,10 +140,11 @@ func loadBalancerSchema(ctx context.Context) schema.Schema {
func (r *loadBalancerResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = loadBalancerSchema(ctx)
resp.Schema.Attributes["timeouts"] = timeouts.Attributes(ctx, timeouts.Opts{
Create: true,
Update: true,
})
resp.Schema.Attributes["timeouts"] =
timeouts.Attributes(ctx, timeouts.Opts{
Create: true,
Update: true,
})
}
func (r *loadBalancerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
@@ -403,35 +404,49 @@ func (r *loadBalancerResource) ImportState(
}
// Import by name
params := binarylane.GetLoadBalancersParams{
Name: &req.ID,
}
var page int32 = 1
perPage := int32(200)
var loadBalancer binarylane.LoadBalancer
var nextPage bool = true
lbResp, err := r.bc.client.GetLoadBalancersWithResponse(ctx, &params)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Error getting load balancer for import: name=%s", req.ID), err.Error())
return
}
for nextPage { // Need to paginate because the API does not support filtering by name
params := binarylane.GetLoadBalancersParams{
Page: &page,
PerPage: &perPage,
}
if lbResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code getting load balancer for import",
fmt.Sprintf("Received %s reading load balancer: name=%s. Details: %s", lbResp.Status(), req.ID,
lbResp.Body))
return
}
for _, lb := range lbResp.JSON200.LoadBalancers {
if lb.Name == req.ID {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), lb.Id)...)
lbResp, err := r.bc.client.GetLoadBalancersWithResponse(ctx, &params)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Error getting load balancer for import: name=%s", req.ID), err.Error())
return
}
if lbResp.StatusCode() != http.StatusOK {
resp.Diagnostics.AddError(
"Unexpected HTTP status code getting load balancer for import",
fmt.Sprintf("Received %s reading load balancer: name=%s. Details: %s", lbResp.Status(), req.ID,
lbResp.Body))
return
}
loadBalancers := lbResp.JSON200.LoadBalancers
for _, lb := range loadBalancers {
if lb.Name == req.ID {
loadBalancer = lb
nextPage = false
break
}
}
if lbResp.JSON200.Links == nil || lbResp.JSON200.Links.Pages.Next == nil {
nextPage = false
break
}
page++
}
resp.Diagnostics.AddError(
"Load balancer not found",
fmt.Sprintf("No load balancer named %q was found to import.", req.ID),
)
diags := resp.State.SetAttribute(ctx, path.Root("id"), loadBalancer.Id)
resp.Diagnostics.Append(diags...)
}
func setLoadBalancerModelState(ctx context.Context, data *loadBalancerDataModel, lb *binarylane.LoadBalancer) diag.Diagnostics {
@@ -28,7 +28,7 @@ func TestLoadBalancerResource(t *testing.T) {
resource "binarylane_server" "test" {
count = 2
name = "tf-test-lb-server-${count.index}"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
password = "` + password + `"
@@ -94,7 +94,7 @@ data "binarylane_load_balancer" "test" {
resource "binarylane_server" "test" {
count = 2
name = "tf-test-lb-server-${count.index}"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
password = "` + password + `"
+1
View File
@@ -103,6 +103,7 @@ func (p *binarylaneProvider) Resources(_ context.Context) []func() resource.Reso
return []func() resource.Resource{
NewServerResource,
NewServerFirewallRulesResource,
NewServerReverseNameResource,
NewSshKeyResource,
NewVpcResource,
NewVpcRouteEntriesResource,
-18
View File
@@ -15,24 +15,6 @@ provider "binarylane" {
# End of provider config
`
// planOnlyProviderConfig configures the provider with a placeholder token, for tests that
// only ever plan and so never reach the API. providerConfig leaves the token to
// BINARYLANE_API_TOKEN, which those tests would otherwise need set to run at all.
planOnlyProviderConfig = `
provider "binarylane" {
api_token = "placeholder-never-used"
}
# End of provider config
`
// testRegion is the BinaryLane region acceptance tests create servers in.
// TEMPORARY: set to "mel" because "per" is intermittently returning "Unable to find a
// suitable host for the requested server configuration". Revert to "per" once that's
// resolved.
testRegion = "mel"
)
var (
-2
View File
@@ -118,8 +118,6 @@ func (d *serverDataSource) Read(ctx context.Context, req datasource.ReadRequest,
data.Permalink = types.StringValue(*serverResp.JSON200.Server.Permalink)
data.Memory = types.Int32Value(serverResp.JSON200.Server.Memory)
data.Disk = types.Int32Value(serverResp.JSON200.Server.Disk)
data.Disks, diag = serverDisksToList(ctx, serverResp.JSON200.Server.Disks, data.Disks)
diags.Append(diag...)
data.SourceAndDestinationCheck = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck)
data.SeparatePrivateNetworkInterface = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SeparatePrivateNetworkInterface)
@@ -121,6 +121,11 @@ func (r *serverFirewallRulesResource) Create(ctx context.Context, req resource.C
return
}
resp.Diagnostics.Append(resolveUnknownDestinationPorts(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
@@ -207,6 +212,11 @@ func (r *serverFirewallRulesResource) Update(ctx context.Context, req resource.U
return
}
resp.Diagnostics.Append(resolveUnknownDestinationPorts(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
@@ -245,6 +255,22 @@ func (r *serverFirewallRulesResource) Delete(ctx context.Context, req resource.D
}
}
// Omitted destination_ports (all ports) are planned as unknown because the attribute is computed.
func resolveUnknownDestinationPorts(ctx context.Context, data *serverFirewallRulesResourceModel) diag.Diagnostics {
var rules []resources.FirewallRulesValue
diags := data.FirewallRules.ElementsAs(ctx, &rules, false)
if diags.HasError() {
return diags
}
for i := range rules {
if rules[i].DestinationPorts.IsUnknown() {
rules[i].DestinationPorts = types.ListNull(types.StringType)
}
}
data.FirewallRules, diags = types.ListValueFrom(ctx, resources.FirewallRulesValue{}.Type(ctx), rules)
return diags
}
func GetFirewallRulesState(ctx context.Context, firewallRules *[]binarylane.AdvancedFirewallRule) (basetypes.ListValue, diag.Diagnostics) {
var diags diag.Diagnostics
@@ -258,7 +284,7 @@ func GetFirewallRulesState(ctx context.Context, firewallRules *[]binarylane.Adva
destinationAddresses, diag := types.ListValueFrom(ctx, types.StringType, rule.DestinationAddresses)
diags.Append(diag...)
var destinationPorts basetypes.ListValue
destinationPorts := types.ListNull(types.StringType)
if rule.DestinationPorts != nil {
destinationPorts, diag = types.ListValueFrom(ctx, types.StringType, rule.DestinationPorts)
diags.Append(diag...)
@@ -20,7 +20,7 @@ func TestServerFirewallRulesResource(t *testing.T) {
resource "binarylane_server" "test" {
name = "tf-test-server-fw-rules"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
password = "` + password + `"
@@ -90,7 +90,7 @@ data "binarylane_server_firewall_rules" "test" {
resource "binarylane_server" "test" {
name = "tf-test-server-fw-rules"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
password = "` + password + `"
@@ -0,0 +1,94 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"terraform-provider-binarylane/internal/binarylane"
"terraform-provider-binarylane/internal/resources"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"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 TestServerFirewallRulesResourceOmittedPorts(t *testing.T) {
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"firewall_rules":[{"action":"accept","protocol":"icmp","source_addresses":["10.0.0.0/8"],"destination_addresses":["10.0.0.1"],"destination_ports":null}]}`))
return
}
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
client, err := binarylane.NewClientWithConfig(srv.URL, "test")
if err != nil {
t.Fatal(err)
}
r := &serverFirewallRulesResource{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)}
}
ruleType := resources.FirewallRulesValue{}
rule := resources.NewFirewallRulesValueMust(ruleType.AttributeTypes(ctx), map[string]attr.Value{
"description": types.StringNull(),
"action": types.StringValue("accept"),
"protocol": types.StringValue("icmp"),
"destination_addresses": types.ListValueMust(types.StringType, []attr.Value{types.StringValue("10.0.0.1")}),
"destination_ports": types.ListUnknown(types.StringType),
"source_addresses": types.ListValueMust(types.StringType, []attr.Value{types.StringValue("10.0.0.0/8")}),
})
model := serverFirewallRulesResourceModel{}
model.ServerId = types.Int64Value(1)
model.FirewallRules = types.ListValueMust(ruleType.Type(ctx), []attr.Value{rule})
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)
}
var ports types.List
createResp.State.GetAttribute(ctx, path.Root("firewall_rules").AtListIndex(0).AtName("destination_ports"), &ports)
if !ports.IsNull() {
t.Fatalf("create: expected null destination_ports, got %s", ports)
}
updateResp := resource.UpdateResponse{State: emptyState()}
r.Update(ctx, resource.UpdateRequest{Plan: plan}, &updateResp)
if updateResp.Diagnostics.HasError() {
t.Fatal(updateResp.Diagnostics)
}
updateResp.State.GetAttribute(ctx, path.Root("firewall_rules").AtListIndex(0).AtName("destination_ports"), &ports)
if !ports.IsNull() {
t.Fatalf("update: expected null destination_ports, got %s", ports)
}
readResp := resource.ReadResponse{State: createResp.State}
r.Read(ctx, resource.ReadRequest{State: createResp.State}, &readResp)
if readResp.Diagnostics.HasError() {
t.Fatal(readResp.Diagnostics)
}
readResp.State.GetAttribute(ctx, path.Root("firewall_rules").AtListIndex(0).AtName("destination_ports"), &ports)
if !ports.IsNull() {
t.Fatalf("read: expected null destination_ports, got %s", ports)
}
}
+50 -461
View File
@@ -1,10 +1,8 @@
package provider
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
@@ -357,8 +355,8 @@ func serverSchema(ctx context.Context) schema.Schema {
},
}
diskDescription := "The total storage in GB for this server. Leave null to accept the default for the size."
diskValidValues := " Valid values must be a multiple of 5. If the value is greater than 60 GB, it must be a multiple of 10. " +
diskDescription := `The total storage in GB for this server. Leave null to accept the default for the size`
diskValidValues := "Valid values must be a multiple of 5. If the value is greater than 60 GB, it must be a multiple of 10. " +
"if the value is greater than 200 GB, it must be a multiple of 100. "
diskValidValuesMarkdown := ` Valid values:
- must be a multiple of 5
@@ -425,21 +423,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
return
}
// `disks` includes the primary, so more than one element means an additional disk.
warnDisksMayRaceUserData := func() {
if plan.UserData.ValueString() == "" || plan.Disks.IsNull() || plan.Disks.IsUnknown() || len(plan.Disks.Elements()) < 2 {
return
}
resp.Diagnostics.AddAttributeWarning(
path.Root("disks"),
"Additional disks may race with user_data on first boot",
"Additional disks are attached after the server boots into a fresh OS install (on create, or a "+
"rebuild triggered by this plan), which can interrupt `user_data` (cloud-init) while it is "+
"still running on first boot. cloud-init only runs user_data once per instance, so any "+
"scripts that did not complete before the disk operations will not re-run on the next boot.",
)
}
if plan.SourceAndDestinationCheck.IsUnknown() {
if plan.VpcId.IsNull() {
plan.SourceAndDestinationCheck = types.BoolNull()
@@ -468,8 +451,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
}
if req.State.Raw.IsNull() {
warnDisksMayRaceUserData()
// Creation plan, no further modification needed
return
}
@@ -517,7 +498,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
strings.Join(attrsRequiringRebuild, ", "),
),
)
warnDisksMayRaceUserData()
}
if !plan.Ipv6.Equal(state.Ipv6) {
@@ -528,11 +508,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
plan.PublicIpv6Addresses = types.ListNull(state.PublicIpv6Addresses.ElementType(ctx))
plan.PrivateIpv6Addresses = types.ListNull(state.PrivateIpv6Addresses.ElementType(ctx))
}
} else {
// Computed list attributes aren't carried forward from state by default, so without
// this these would plan as unknown on every apply, even when ipv6 didn't change.
plan.PublicIpv6Addresses = state.PublicIpv6Addresses
plan.PrivateIpv6Addresses = state.PrivateIpv6Addresses
}
// Use state for unknown disk/memory values, as long as server size is the same
@@ -543,32 +518,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
plan.Disk = state.Disk
}
// A configured `disks` is planned by the attribute's own plan modifier, which matches each
// disk with the one it corresponds to in prior state. Left unset, the disks are Binary Lane's
// to manage, and what happens to them depends on `disk` — resolved just above, after that
// plan modifier has already run.
if config.Disks.IsNull() || config.Disks.IsUnknown() {
// Computed list attributes aren't carried forward from state by default, so without this
// `disks` would plan as unknown on every apply, even when nothing about it changed.
plan.Disks = state.Disks
if state.Disks.IsNull() || state.Disks.IsUnknown() {
plan.Disks = types.ListUnknown(resources.DisksValue{}.Type(ctx))
} else if len(state.Disks.Elements()) == 1 && !plan.Disk.Equal(state.Disk) {
// A resize resizes the primary disk to fill the new total, but only when it is the
// server's only disk: with an additional disk it leaves every disk alone.
plan.Disks = types.ListUnknown(resources.DisksValue{}.Type(ctx))
statePrimary, _, diags := splitDisks(ctx, state.Disks)
resp.Diagnostics.Append(diags...)
if statePrimary != nil && !plan.Disk.IsUnknown() &&
float64(plan.Disk.ValueInt32()) < statePrimary.SizeGigabytes {
resp.Diagnostics.Append(resources.DiskShrinkWarning(path.Root("disk"), "The primary disk",
int64(statePrimary.SizeGigabytes), int64(plan.Disk.ValueInt32())))
}
}
}
if isAdvFeatChanged(&config.AdvancedFeatures, &state.AdvancedFeatures) {
advFeatResp, err := r.bc.client.GetServersServerIdAvailableAdvancedFeaturesWithResponse(ctx, state.Id.ValueInt64())
if err != nil {
@@ -630,11 +579,6 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
)
}
}
} else {
// Computed object attributes aren't carried forward from state by default, so without
// this `advanced_features` would plan as unknown on every apply, even when none of its
// writable fields changed.
plan.AdvancedFeatures = state.AdvancedFeatures
}
// Save data into Terraform state
@@ -683,14 +627,6 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
SeparatePrivateNetworkInterface: data.SeparatePrivateNetworkInterface.ValueBoolPointer(),
}
// The planned `disks` is unknown when it isn't configured, so the disks to create are read
// from config, which is always known.
configPrimary, configAdditional, diskDiags := splitDisks(ctx, config.Disks)
resp.Diagnostics.Append(diskDiags...)
if resp.Diagnostics.HasError() {
return
}
if !data.Memory.IsNull() && !data.Memory.IsUnknown() {
body.Options.Memory = data.Memory.ValueInt32Pointer()
}
@@ -755,8 +691,6 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
data.PasswordChangeSupported = types.BoolValue(serverResp.JSON200.Server.PasswordChangeSupported)
data.Memory = types.Int32Value(serverResp.JSON200.Server.Memory)
data.Disk = types.Int32Value(serverResp.JSON200.Server.Disk)
data.Disks, diags = serverDisksToList(ctx, serverResp.JSON200.Server.Disks, config.Disks)
resp.Diagnostics.Append(diags...)
plannedSourceDestCheck := data.SourceAndDestinationCheck
serverRespSourceDestCheck := types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck)
data.SourceAndDestinationCheck = serverRespSourceDestCheck
@@ -836,25 +770,6 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
data.SourceAndDestinationCheck = plannedSourceDestCheck
}
// Carve the additional disks out of the server's total: it is created with a single primary
// disk spanning all of it, so shrink that to make room, as Binary Lane's own UI does
// (https://support.binarylane.com.au/support/solutions/articles/11000133468). If cloud-init's
// growpart has already expanded the filesystem to fill the original primary, this can
// corrupt it, and a disk can only be resized in place.
if configPrimary != nil {
finalDisks, err := r.reconcileServerDisks(
ctx, data.Id.ValueInt64(), serverResp.JSON200.Server.Disks,
configPrimary.SizeGigabytes, configAdditional, true,
)
data.Disks, diags = serverDisksToList(ctx, finalDisks, config.Disks)
resp.Diagnostics.Append(diags...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
if err != nil {
resp.Diagnostics.AddError("Error creating additional disks", err.Error())
return
}
}
// 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)
@@ -902,6 +817,7 @@ func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, res
}
func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var diag diag.Diagnostics
var config, plan, state serverResourceModel
// Read Terraform plan data into the model
@@ -1044,68 +960,28 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
}
}
// The disks to reconcile come from config, which describes every disk when `disks` is set.
// Left unset, the disks are Binary Lane's to manage and there is nothing to reconcile.
configPrimary, configAdditional, diags := splitDisks(ctx, config.Disks)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
planDiskKnown := !plan.Disk.IsNull() && !plan.Disk.IsUnknown()
diskChanged := planDiskKnown && !plan.Disk.Equal(state.Disk)
sizeChanged := !plan.Size.Equal(state.Size)
// A `disk` or `size` change resizes the server, which can resize the primary disk with it, so
// the disks are reconciled back to config after one of those too.
reconcileDisks := configPrimary != nil &&
(!plan.Disks.Equal(state.Disks) || diskChanged || sizeChanged)
persistDisks := func(disks []binarylane.Disk) {
if disks == nil {
return
}
var diags diag.Diagnostics
state.Disks, diags = serverDisksToList(ctx, disks, plan.Disks)
resp.Diagnostics.Append(diags...)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Free space before the resize below, which is where space to grow or add a disk comes from.
var currentDisks []binarylane.Disk
if reconcileDisks {
var err error
currentDisks, err = r.reconcileServerDisks(
ctx, state.Id.ValueInt64(), nil, configPrimary.SizeGigabytes, configAdditional, false)
persistDisks(currentDisks)
if err != nil {
resp.Diagnostics.AddError("Error reconciling server disks", err.Error())
return
}
}
// Resize operation
memoryChanged := !plan.Memory.IsNull() && !plan.Memory.IsUnknown() && !plan.Memory.Equal(state.Memory)
if sizeChanged || memoryChanged ||
if !plan.Size.Equal(state.Size) ||
!plan.Memory.IsNull() && !plan.Memory.IsUnknown() && !plan.Memory.Equal(state.Memory) ||
!plan.Disk.IsNull() && !plan.Disk.IsUnknown() && !plan.Disk.Equal(state.Disk) ||
!plan.Image.Equal(state.Image) ||
!plan.PublicIpv4Count.Equal(state.PublicIpv4Count) ||
diskChanged {
!plan.PublicIpv4Count.Equal(state.PublicIpv4Count) {
resizeReq := &binarylane.PostServersServerIdActionsResizeJSONRequestBody{
Type: "resize",
Options: &binarylane.ChangeSizeOptionsRequest{},
}
if sizeChanged || memoryChanged || diskChanged {
if sizeChanged {
resizeReq.Size = plan.Size.ValueStringPointer()
}
if !plan.Size.Equal(state.Size) ||
!plan.Memory.IsNull() && !plan.Memory.IsUnknown() && !plan.Memory.Equal(state.Memory) ||
!plan.Disk.IsNull() && !plan.Disk.IsUnknown() && !plan.Disk.Equal(state.Disk) {
resizeReq.Size = plan.Size.ValueStringPointer()
if !plan.Memory.IsUnknown() && !plan.Memory.IsNull() {
resizeReq.Options.Memory = plan.Memory.ValueInt32Pointer()
}
if planDiskKnown {
disk := plan.Disk.ValueInt32()
resizeReq.Options.Disk = &disk
if !plan.Disk.IsNull() && !plan.Disk.IsUnknown() {
resizeReq.Options.Disk = plan.Disk.ValueInt32Pointer()
}
state.Size = plan.Size
state.Memory = plan.Memory
@@ -1166,9 +1042,6 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
return
}
// The resize can resize the primary disk, so anything read before it is stale.
currentDisks = nil
if state.PublicIpv4Addresses.IsUnknown() || listContainsUnknown(ctx, state.PublicIpv4Addresses) || state.Memory.IsNull() || state.Memory.IsUnknown() {
refreshNeeded = true
}
@@ -1180,18 +1053,6 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
}
}
// Grow the primary back to its target size, then grow and add the rest, now that the resize
// has made room. currentDisks is nil if that resize ran, so they are re-read.
if reconcileDisks {
finalDisks, err := r.reconcileServerDisks(
ctx, state.Id.ValueInt64(), currentDisks, configPrimary.SizeGigabytes, configAdditional, true)
persistDisks(finalDisks)
if err != nil {
resp.Diagnostics.AddError("Error reconciling server disks", err.Error())
return
}
}
// Change IPv6
if !plan.Ipv6.Equal(state.Ipv6) {
ipv6Resp, err := r.bc.client.PostServersServerIdActionsChangeIpv6WithResponse(
@@ -1313,6 +1174,7 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
resp.Diagnostics.AddError("Error updating advanced features", err.Error())
return
}
resp.Diagnostics.Append(diag...)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
@@ -1499,28 +1361,36 @@ func (r *serverResource) ImportState(
resp.Diagnostics.Append(diags...)
}
var (
serverActionPollInterval = 5 * time.Second
serverActionTimeout = 20 * time.Minute
)
func (r *serverResource) waitForServerAction(ctx context.Context, serverId int64, actionId int64) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, serverActionTimeout)
defer cancel()
}
var lastReadyResp *binarylane.GetServersServerIdActionsActionIdResponse
for {
select {
case <-ctx.Done():
if lastReadyResp == nil {
return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d", serverId, actionId)
} else {
return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d, last response was status=%s, body: %s",
serverId, actionId, lastReadyResp.Status(), lastReadyResp.Body)
readyResp, err := r.bc.client.GetServersServerIdActionsActionIdWithResponse(ctx, serverId, actionId)
if err != nil && ctx.Err() == nil {
return fmt.Errorf("unexpected error waiting for server action: server_id=%d, action_id=%d, error: %w", serverId, actionId, err)
}
if err == nil {
if readyResp.StatusCode() == http.StatusNotFound {
return fmt.Errorf("server action not found: server_id=%d, action_id=%d, body: %s", serverId, actionId, readyResp.Body)
}
default:
readyResp, err := r.bc.client.GetServersServerIdActionsActionIdWithResponse(ctx, serverId, actionId)
if err != nil {
return fmt.Errorf("unexpected error waiting for server action: server_id=%d, action_id=%d, error: %w", serverId, actionId, err)
}
if readyResp.StatusCode() == http.StatusOK && readyResp.JSON200.Action.Status == binarylane.Errored {
return fmt.Errorf("server action failed to with error: server_id=%d, action_id=%d, error: %s", serverId, actionId, readyResp.Body)
}
if readyResp.StatusCode() == http.StatusOK && readyResp.JSON200.Action.CompletedAt != nil {
return nil
if readyResp.StatusCode() == http.StatusOK {
switch {
case readyResp.JSON200.Action.Status == binarylane.Errored:
return fmt.Errorf("server action failed to with error: server_id=%d, action_id=%d, error: %s", serverId, actionId, readyResp.Body)
case readyResp.JSON200.Action.Status == binarylane.Completed, readyResp.JSON200.Action.CompletedAt != nil:
return nil
}
}
lastReadyResp = readyResp
tflog.Debug(ctx,
@@ -1529,7 +1399,16 @@ func (r *serverResource) waitForServerAction(ctx context.Context, serverId int64
),
)
}
time.Sleep(time.Second * 5)
select {
case <-ctx.Done():
if lastReadyResp == nil {
return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d", serverId, actionId)
}
return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d, last response was status=%s, body: %s",
serverId, actionId, lastReadyResp.Status(), lastReadyResp.Body)
case <-time.After(serverActionPollInterval):
}
}
}
@@ -1646,9 +1525,6 @@ func (r *serverResource) fetchServerResourceState(ctx context.Context, state *se
state.SeparatePrivateNetworkInterface = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SeparatePrivateNetworkInterface)
state.Memory = types.Int32Value(serverResp.JSON200.Server.Memory)
state.Disk = types.Int32Value(serverResp.JSON200.Server.Disk)
var readDiags diag.Diagnostics
state.Disks, readDiags = serverDisksToList(ctx, serverResp.JSON200.Server.Disks, state.Disks)
diags.Append(readDiags...)
state.Backups = types.BoolValue(serverResp.JSON200.Server.NextBackupWindow != nil)
state.Ipv6 = types.BoolValue(len(serverResp.JSON200.Server.Networks.V6) > 0)
@@ -1847,290 +1723,3 @@ func (r *serverResource) updateAdvancedFeatures(
return nil
}
// serverDisksToList converts the server's disks into the `disks` attribute value, ordered to
// match `order` — the value Terraform already holds, either planned or from prior state. A
// list's elements must stay where they were planned, so a configuration can list disks in any
// order as long as this keeps to it.
func serverDisksToList(ctx context.Context, apiDisks []binarylane.Disk, order types.List) (types.List, diag.Diagnostics) {
var diags diag.Diagnostics
var ordered []resources.DisksValue
if !order.IsNull() && !order.IsUnknown() {
diags.Append(order.ElementsAs(ctx, &ordered, true)...)
}
orderKeys := resources.DiskKeyer{}
rank := make(map[resources.DiskKey]int, len(ordered))
for i, d := range ordered {
rank[orderKeys.Of(d.Primary.ValueBool(), d.Description.ValueString())] = i
}
primaryFirst := func(d binarylane.Disk) int {
if d.Primary {
return 0
}
return 1
}
// Sort into a stable order first, to key the disks in and to fall back on.
disks := slices.Clone(apiDisks)
slices.SortStableFunc(disks, func(a, b binarylane.Disk) int {
return cmp.Or(cmp.Compare(primaryFirst(a), primaryFirst(b)), cmp.Compare(a.Id, b.Id))
})
// Disks the order doesn't mention keep that order, after those it does.
diskKeys := resources.DiskKeyer{}
rankById := make(map[int64]int, len(disks))
for _, d := range disks {
r, ranked := rank[diskKeys.Of(d.Primary, diskDescription(d))]
if !ranked {
r = len(rank)
}
rankById[d.Id] = r
}
slices.SortStableFunc(disks, func(a, b binarylane.Disk) int {
return cmp.Compare(rankById[a.Id], rankById[b.Id])
})
list, listDiags := types.ListValueFrom(ctx, resources.DisksValue{}.Type(ctx), disks)
diags.Append(listDiags...)
return list, diags
}
// splitDisks divides a `disks` list into its primary entry and the additional disks, as the API's
// own type: binarylane.Disk carries tfsdk tags for exactly the attributes `disks` has, so the
// values reflect straight into it. The attributes Binary Lane assigns are null in a configuration
// and come back as their zero value. primary is nil when the list is null or unknown, or has no
// entry with `primary = true`.
func splitDisks(ctx context.Context, list types.List) (primary *binarylane.Disk, additional []binarylane.Disk, diags diag.Diagnostics) {
if list.IsNull() || list.IsUnknown() {
return nil, nil, nil
}
var disks []binarylane.Disk
diags = list.ElementsAs(ctx, &disks, true)
if diags.HasError() {
return nil, nil, diags
}
for i, d := range disks {
if d.Primary && primary == nil {
primary = &disks[i]
continue
}
additional = append(additional, d)
}
return primary, additional, diags
}
// reconcileServerDisks brings the server's disks in line with desiredPrimary and
// desiredAdditional, and returns them as they stand afterwards, re-read if anything changed —
// including after a failure, since partially applied disk changes are not rolled back. current
// is the disks as last read, or nil to read them here.
//
// Deleting and shrinking only free space, so they always run. Growing the primary, growing a
// disk and adding one all claim space the server's total may not have yet, so they wait for
// growAndAdd: Update calls this before its resize action with growAndAdd false and after it
// with true, while Create has no resize of its own and always passes true.
func (r *serverResource) reconcileServerDisks(
ctx context.Context,
serverId int64,
current []binarylane.Disk,
desiredPrimary float64,
desiredAdditional []binarylane.Disk,
growAndAdd bool,
) ([]binarylane.Disk, error) {
if current == nil {
var err error
if current, err = r.fetchServerDisks(ctx, serverId); err != nil {
return nil, err
}
}
mutated, err := r.applyDiskChanges(ctx, serverId, current, desiredPrimary, desiredAdditional, growAndAdd)
if !mutated {
return current, err
}
disks, fetchErr := r.fetchServerDisks(ctx, serverId)
if fetchErr != nil {
return current, errors.Join(err, fetchErr)
}
return disks, err
}
// applyDiskChanges makes the changes reconcileServerDisks needs, reporting whether it attempted
// any.
func (r *serverResource) applyDiskChanges(
ctx context.Context,
serverId int64,
current []binarylane.Disk,
desiredPrimary float64,
desiredAdditional []binarylane.Disk,
growAndAdd bool,
) (mutated bool, err error) {
desiredKeys := make([]resources.DiskKey, len(desiredAdditional))
desiredSizes := make(map[resources.DiskKey]float64, len(desiredAdditional))
keyer := resources.DiskKeyer{}
for i, d := range desiredAdditional {
desiredKeys[i] = keyer.Of(false, diskDescription(d))
desiredSizes[desiredKeys[i]] = d.SizeGigabytes
}
// Keyed by id order, which is the order the desired disks were planned against.
byId := slices.Clone(current)
slices.SortStableFunc(byId, func(a, b binarylane.Disk) int { return cmp.Compare(a.Id, b.Id) })
var primary *binarylane.Disk
currentKeys := make(map[int64]resources.DiskKey, len(current))
currentByKey := make(map[resources.DiskKey]binarylane.Disk, len(current))
keyer = resources.DiskKeyer{}
for i, d := range byId {
if d.Primary {
primary = &byId[i]
continue
}
key := keyer.Of(false, diskDescription(d))
currentKeys[d.Id] = key
currentByKey[key] = d
}
// Delete disks that are no longer wanted and shrink those that are getting smaller.
for _, d := range current {
if d.Primary {
continue
}
desired, wanted := desiredSizes[currentKeys[d.Id]]
switch {
case !wanted:
mutated = true
if err := r.deleteServerDisk(ctx, serverId, d.Id); err != nil {
return true, err
}
case desired < d.SizeGigabytes:
mutated = true
if err := r.resizeServerDisk(ctx, serverId, d.Id, desired); err != nil {
return true, err
}
}
}
// The primary shrinks before a server-level resize and grows after it, never both.
if primary != nil && (desiredPrimary < primary.SizeGigabytes ||
(growAndAdd && desiredPrimary > primary.SizeGigabytes)) {
mutated = true
if err := r.resizeServerDisk(ctx, serverId, primary.Id, desiredPrimary); err != nil {
return true, err
}
}
if !growAndAdd {
return mutated, nil
}
for i, d := range desiredAdditional {
if existing, ok := currentByKey[desiredKeys[i]]; ok {
if d.SizeGigabytes > existing.SizeGigabytes {
mutated = true
if err := r.resizeServerDisk(ctx, serverId, existing.Id, d.SizeGigabytes); err != nil {
return true, err
}
}
continue
}
mutated = true
if err := r.addServerDisk(ctx, serverId, diskDescription(d), d.SizeGigabytes); err != nil {
return true, err
}
}
return mutated, nil
}
// diskDescription returns a disk's description, which is unset for a disk added without one.
func diskDescription(d binarylane.Disk) string {
if d.Description == nil {
return ""
}
return *d.Description
}
func (r *serverResource) fetchServerDisks(ctx context.Context, serverId int64) ([]binarylane.Disk, error) {
resp, err := r.bc.client.GetServersServerIdWithResponse(ctx, serverId)
if err != nil {
return nil, fmt.Errorf("error fetching server disks: server_id=%d, error: %w", serverId, err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP status code fetching server disks: server_id=%d, status=%s, body: %s", serverId, resp.Status(), resp.Body)
}
return resp.JSON200.Server.Disks, nil
}
func (r *serverResource) resizeServerDisk(ctx context.Context, serverId int64, diskId int64, sizeGigabytes float64) error {
tflog.Info(ctx, fmt.Sprintf("Resizing disk: server_id=%d, disk_id=%d, size_gigabytes=%g", serverId, diskId, sizeGigabytes))
resizeResp, err := r.bc.client.PostServersServerIdActionsResizeDiskWithResponse(ctx, serverId, binarylane.ResizeDisk{
Type: binarylane.ResizeDiskTypeResizeDisk,
DiskId: diskId,
SizeGigabytes: int32(sizeGigabytes),
})
if err != nil {
return fmt.Errorf("error resizing disk: server_id=%d, disk_id=%d, error: %w", serverId, diskId, err)
}
if resizeResp.StatusCode() != http.StatusOK {
return fmt.Errorf("unexpected HTTP status code resizing disk: server_id=%d, disk_id=%d, details: %s", serverId, diskId, resizeResp.Body)
}
err = r.waitForServerAction(ctx, serverId, resizeResp.JSON200.Action.Id)
if err != nil {
return fmt.Errorf("error resizing disk: %w", err)
}
return nil
}
func (r *serverResource) deleteServerDisk(ctx context.Context, serverId int64, diskId int64) error {
tflog.Info(ctx, fmt.Sprintf("Deleting disk: server_id=%d, disk_id=%d", serverId, diskId))
deleteResp, err := r.bc.client.PostServersServerIdActionsDeleteDiskWithResponse(ctx, serverId, binarylane.DeleteDisk{
Type: binarylane.DeleteDiskTypeDeleteDisk,
DiskId: diskId,
})
if err != nil {
return fmt.Errorf("error deleting disk: server_id=%d, disk_id=%d, error: %w", serverId, diskId, err)
}
if deleteResp.StatusCode() != http.StatusOK {
return fmt.Errorf("unexpected HTTP status code deleting disk: server_id=%d, disk_id=%d, details: %s", serverId, diskId, deleteResp.Body)
}
err = r.waitForServerAction(ctx, serverId, deleteResp.JSON200.Action.Id)
if err != nil {
return fmt.Errorf("error deleting disk: %w", err)
}
return nil
}
func (r *serverResource) addServerDisk(ctx context.Context, serverId int64, description string, sizeGigabytes float64) error {
tflog.Info(ctx, fmt.Sprintf("Adding disk: server_id=%d, description=%s, size_gigabytes=%g", serverId, description, sizeGigabytes))
addDisk := binarylane.AddDisk{
Type: binarylane.AddDiskTypeAddDisk,
SizeGigabytes: int32(sizeGigabytes),
}
if description != "" {
addDisk.Description = &description
}
addResp, err := r.bc.client.PostServersServerIdActionsAddDiskWithResponse(ctx, serverId, addDisk)
if err != nil {
return fmt.Errorf("error adding disk: server_id=%d, description=%s, error: %w", serverId, description, err)
}
if addResp.StatusCode() != http.StatusOK {
return fmt.Errorf("unexpected HTTP status code adding disk: server_id=%d, description=%s, details: %s", serverId, description, addResp.Body)
}
err = r.waitForServerAction(ctx, serverId, addResp.JSON200.Action.Id)
if err != nil {
return fmt.Errorf("error adding disk: %w", err)
}
return nil
}
+19 -352
View File
@@ -23,10 +23,6 @@ func TestServerResource(t *testing.T) {
sshPublicKeyInitial := GenerateTestPublicKey(t)
sshPublicKeyUpdated := GenerateTestPublicKey(t)
// Captured while "zeta" still exists, then checked once it is gone and the disks after it
// have shifted down a position.
var alphaDiskId, betaDiskId string
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
@@ -53,7 +49,7 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
memory = 1152
@@ -81,16 +77,11 @@ data "binarylane_server" "test" {
// Verify resource values
resource.TestCheckResourceAttrSet("binarylane_server.test", "id"),
resource.TestCheckResourceAttr("binarylane_server.test", "name", "tf-test-server-resource"),
resource.TestCheckResourceAttr("binarylane_server.test", "region", testRegion),
resource.TestCheckResourceAttr("binarylane_server.test", "region", "per"),
resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-11"),
resource.TestCheckResourceAttr("binarylane_server.test", "size", "std-min"),
resource.TestCheckResourceAttr("binarylane_server.test", "memory", "1152"),
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "20"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "1"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "20"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "vpc_id"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "vpc_ipv4_address"),
resource.TestCheckResourceAttr("binarylane_server.test", "public_ipv4_count", "1"),
@@ -123,7 +114,7 @@ echo "Hello World" > /var/tmp/output.txt
// Verify data source values
resource.TestCheckResourceAttrPair("data.binarylane_server.test", "id", "binarylane_server.test", "id"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "name", "tf-test-server-resource"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "region", testRegion),
resource.TestCheckResourceAttr("data.binarylane_server.test", "region", "per"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "image", "debian-11"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "size", "std-min"),
resource.TestCheckResourceAttrSet("data.binarylane_server.test", "vpc_id"),
@@ -134,11 +125,6 @@ echo "Hello World" > /var/tmp/output.txt
`),
resource.TestCheckResourceAttr("data.binarylane_server.test", "memory", "1152"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disk", "20"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.#", "1"),
resource.TestCheckResourceAttrSet("data.binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.size_gigabytes", "20"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "backups", "true"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "port_blocking", "false"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "source_and_destination_check", "false"),
@@ -194,16 +180,10 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource-2"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
disk = 40
disks = [
{ primary = true, size_gigabytes = 25 },
{ description = "zeta", size_gigabytes = 5 },
{ description = "alpha", size_gigabytes = 5 },
{ description = "beta", size_gigabytes = 5 },
]
size = "std-1vcpu"
disk = "45"
password = "` + password1 + `"
vpc_id = null
public_ipv4_count = 0
@@ -227,34 +207,9 @@ EOT
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("binarylane_server.test", "name", "tf-test-server-resource-2"),
resource.TestCheckResourceAttr("binarylane_server.test", "size", "std-min"),
// `memory` is unset here and `size` is unchanged, so it carries forward
// the 1152 set in the previous step.
resource.TestCheckResourceAttr("binarylane_server.test", "memory", "1152"),
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "40"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "4"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "25"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.description", "zeta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "5"),
resource.TestCheckResourceAttrWith("binarylane_server.test", "disks.2.id", func(value string) error {
alphaDiskId = value
return nil
}),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.description", "alpha"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
resource.TestCheckResourceAttrWith("binarylane_server.test", "disks.3.id", func(value string) error {
betaDiskId = value
return nil
}),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.description", "beta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.size_gigabytes", "5"),
resource.TestCheckResourceAttr("binarylane_server.test", "size", "std-1vcpu"),
resource.TestCheckResourceAttr("binarylane_server.test", "memory", "2048"),
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "45"),
resource.TestCheckResourceAttr("binarylane_server.test", "public_ipv4_count", "0"),
resource.TestCheckResourceAttr("binarylane_server.test", "public_ipv4_addresses.#", "0"),
resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-12"),
@@ -299,16 +254,10 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource-2"
region = "` + testRegion + `"
region = "per"
image = "debian-12"
size = "std-min"
disk = 35
disks = [
{ primary = true, size_gigabytes = 15 },
{ description = "alpha", size_gigabytes = 10 },
{ description = "beta", size_gigabytes = 5 },
{ description = "delta", size_gigabytes = 5 },
]
size = "std-1vcpu"
disk = "45"
password = "` + password2 + `"
vpc_id = null
public_ipv4_count = 0
@@ -329,288 +278,6 @@ EOT
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("binarylane_server.test", "name", "tf-test-server-resource-2"),
resource.TestCheckResourceAttr("binarylane_server.test", "password", password2),
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "35"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "4"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "15"),
// Removing "zeta" shifts these down a position, but each keeps the disk it
// had: they are matched to prior state by description, not by index.
resource.TestCheckResourceAttrWith("binarylane_server.test", "disks.1.id", func(value string) error {
if value != alphaDiskId {
return fmt.Errorf("disk \"alpha\" has id %s, want %s", value, alphaDiskId)
}
return nil
}),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.description", "alpha"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "10"),
resource.TestCheckResourceAttrWith("binarylane_server.test", "disks.2.id", func(value string) error {
if value != betaDiskId {
return fmt.Errorf("disk \"beta\" has id %s, want %s", value, betaDiskId)
}
return nil
}),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.description", "beta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.3.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.description", "delta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.size_gigabytes", "5"),
),
},
// Dropping `disks` leaves the server's disks alone: growing the total here only
// adds unallocated space, since the primary is not the only disk.
{
Config: providerConfig + `
resource "binarylane_ssh_key" "updated" {
name = "tf-test-server-resource-updated"
public_key = "` + sshPublicKeyUpdated + `"
default = true
}
resource "binarylane_server" "test" {
name = "tf-test-server-resource-2"
region = "` + testRegion + `"
image = "debian-12"
size = "std-min"
disk = 50
password = "` + password2 + `"
vpc_id = null
public_ipv4_count = 0
ssh_keys = [binarylane_ssh_key.updated.id]
user_data = <<EOT
#cloud-config
echo "Hello Whitespace" > /var/tmp/output.txt
EOT
}
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "50"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "4"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "15"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.description", "alpha"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "10"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.2.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.description", "beta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.3.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.description", "delta"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.3.size_gigabytes", "5"),
),
},
},
})
}
// TestServerResourceDisksValidation exercises the plan-time `disks` validators. Every case only
// plans a fresh server, so no infrastructure is created.
func TestServerResourceDisksValidation(t *testing.T) {
tests := []struct {
name string
// attributes are added to an otherwise valid server resource.
attributes string
// expectErr is the error the plan must fail with, or empty if it must succeed.
expectErr string
}{
{
name: "no primary disk",
attributes: `disks = [{ description = "data1", size_gigabytes = 10 }]`,
expectErr: "Missing primary disk",
},
{
name: "empty disks list",
attributes: `disks = []`,
expectErr: "Missing primary disk",
},
{
name: "two primary disks",
attributes: `disks = [
{ primary = true, size_gigabytes = 20 },
{ primary = true, size_gigabytes = 10 },
]`,
expectErr: "Multiple primary disks",
},
{
// The primary disk's description is assigned by Binary Lane, not by the user.
name: "primary disk with a description",
attributes: `disks = [{ primary = true, description = "SYSTEM", size_gigabytes = 20 }]`,
expectErr: "Primary disk description is server-assigned",
},
{
name: "disks exceeding the total",
attributes: `disk = 45
disks = [
{ primary = true, size_gigabytes = 30 },
{ description = "data1", size_gigabytes = 20 },
]`,
expectErr: "Disks exceed total disk allocation",
},
{
name: "disk below the minimum",
attributes: `disk = 10`,
expectErr: "Attribute disk",
},
{
// Binary Lane allows duplicate descriptions, and so does this provider.
name: "duplicate descriptions",
attributes: `disks = [
{ primary = true, size_gigabytes = 20 },
{ description = "data", size_gigabytes = 5 },
{ description = "data", size_gigabytes = 10 },
]`,
},
{
// Also valid with room to spare, leaving the difference unallocated.
name: "disks filling the total exactly",
attributes: `disk = 40
disks = [
{ primary = true, size_gigabytes = 30 },
{ description = "data1", size_gigabytes = 10 },
]`,
},
{
name: "disks with the total left unset",
attributes: `disks = [
{ primary = true, size_gigabytes = 30 },
{ description = "data1", size_gigabytes = 10 },
]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
step := resource.TestStep{
Config: planOnlyProviderConfig + fmt.Sprintf(`
resource "binarylane_server" "test" {
name = "tf-test-disks-validation"
region = %q
image = "debian-12"
size = "std-min"
public_ipv4_count = 0
%s
}
`, testRegion, tt.attributes),
PlanOnly: true,
ExpectNonEmptyPlan: true,
}
if tt.expectErr != "" {
step.ExpectError = regexp.MustCompile(tt.expectErr)
}
resource.UnitTest(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{step},
})
})
}
}
func TestServerResourceCreateWithDisks(t *testing.T) {
// Must assign a password to the server or Binary Lane will send emails
password := GenerateTestPassword(t)
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-create-disks"
region = "` + testRegion + `"
image = "debian-12"
size = "std-min"
public_ipv4_count = 0
password = "` + password + `"
disk = 40
disks = [
{ primary = true, size_gigabytes = 20 },
{ description = "data", size_gigabytes = 10 },
{ description = "data", size_gigabytes = 5 },
]
}
`,
Check: resource.ComposeAggregateTestCheckFunc(
// 5 GB of the total is left unallocated.
resource.TestCheckResourceAttr("binarylane_server.test", "disk", "40"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "3"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "20"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.description", "data"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "10"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.2.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.description", "data"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
),
},
// The same disks in a different order, which is not a change.
{
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-create-disks"
region = "` + testRegion + `"
image = "debian-12"
size = "std-min"
public_ipv4_count = 0
password = "` + password + `"
disk = 40
disks = [
{ description = "data", size_gigabytes = 10 },
{ primary = true, size_gigabytes = 20 },
{ description = "data", size_gigabytes = 5 },
]
}
data "binarylane_server" "test" {
depends_on = [binarylane_server.test]
id = binarylane_server.test.id
}
`,
Check: resource.ComposeAggregateTestCheckFunc(
// The resource keeps the order the configuration lists the disks in
resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "3"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.description", "data"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "10"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.primary", "true"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.description", "SYSTEM"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "20"),
resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.2.id"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.description", "data"),
resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
// The data source has no order of its own, so it lists the primary disk
// first and the rest by id
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.#", "3"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.primary", "true"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.description", "SYSTEM"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.0.size_gigabytes", "20"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.1.primary", "false"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.1.description", "data"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.1.size_gigabytes", "10"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.2.primary", "false"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.2.description", "data"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "disks.2.size_gigabytes", "5"),
),
},
},
@@ -630,7 +297,7 @@ func TestServerResourceRename(t *testing.T) {
resource "binarylane_server" "test" {
name = "tf-test-server-rename-1"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -643,7 +310,7 @@ resource "binarylane_server" "test" {
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-rename-2"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -652,7 +319,7 @@ resource "binarylane_server" "test" {
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("binarylane_server.test", "name", "tf-test-server-rename-2"),
resource.TestCheckResourceAttr("binarylane_server.test", "region", testRegion),
resource.TestCheckResourceAttr("binarylane_server.test", "region", "per"),
resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-11"),
resource.TestCheckResourceAttr("binarylane_server.test", "size", "std-min"),
resource.TestCheckResourceAttr("binarylane_server.test", "password", password),
@@ -674,7 +341,7 @@ func TestServerVpcIpv4Change(t *testing.T) {
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -700,7 +367,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -740,7 +407,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -780,7 +447,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "` + testRegion + `"
region = "per"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -0,0 +1,320 @@
package provider
import (
"context"
"fmt"
"net/http"
"net/netip"
"strconv"
"strings"
"terraform-provider-binarylane/internal/binarylane"
"time"
"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)
}
// The change_reverse_name action can stay unresolvable, so wait for the result on the server instead
if reverseName == nil {
return nil
}
return r.waitForReverseName(ctx, serverId, ipv4Address, *reverseName)
}
func (r *serverReverseNameResource) waitForReverseName(ctx context.Context, serverId int64, ipv4Address string, want string) error {
ctx, cancel := context.WithTimeout(ctx, serverActionTimeout)
defer cancel()
var last string
for {
serverResp, err := r.bc.client.GetServersServerIdWithResponse(ctx, serverId)
if err == nil && serverResp.StatusCode() == http.StatusOK {
for _, n := range serverResp.JSON200.Server.Networks.V4 {
if n.IpAddress != ipv4Address {
continue
}
if n.ReverseName != nil {
if *n.ReverseName == want {
return nil
}
last = *n.ReverseName
}
}
}
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for reverse name: server_id=%d, ipv4_address=%s, want=%q, last=%q", serverId, ipv4Address, want, last)
case <-time.After(serverActionPollInterval):
}
}
}
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
}
@@ -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
}
@@ -0,0 +1,222 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"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":
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")
}
serverFound, serverIp, serverName = true, "203.0.113.7", "ptr.example.com"
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)
}
}
// The change_reverse_name action never resolves while the PTR is already applied on the server.
func TestServerReverseNameCreateStuckActionUnit(t *testing.T) {
serverActionPollInterval = time.Millisecond
t.Cleanup(func() { serverActionPollInterval = 5 * time.Second })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
serverGets := 0
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":
_, _ = w.Write([]byte(`{"action":{"id":9,"status":"in-progress"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/servers/1/actions/9":
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == "/servers/1":
serverGets++
name := "default.example.net"
if serverGets > 2 {
name = "ptr.example.com"
}
_, _ = fmt.Fprintf(w, `{"server":{"id":1,"networks":{"v4":[{"ip_address":"203.0.113.7","type":"public","reverse_name":%q}]}}}`, name)
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}}
want := "ptr.example.com"
if err := r.changeReverseName(ctx, 1, "203.0.113.7", &want); err != nil {
t.Fatalf("create must finish once the server reports the reverse name: %v", err)
}
if serverGets != 3 {
t.Fatalf("expected to poll the server until the reverse name applied, got %d GETs", serverGets)
}
}
func TestWaitForServerActionUnit(t *testing.T) {
serverActionPollInterval = time.Millisecond
t.Cleanup(func() { serverActionPollInterval = 5 * time.Second })
for name, tc := range map[string]struct {
status int
body string
wantErr bool
}{
"completed_at set": {http.StatusOK, `{"action":{"id":9,"status":"in-progress","completed_at":"2026-01-01T00:00:00Z"}}`, false},
"completed status only": {http.StatusOK, `{"action":{"id":9,"status":"completed"}}`, false},
"errored": {http.StatusOK, `{"action":{"id":9,"status":"errored"}}`, true},
"not found": {http.StatusNotFound, `{"message":"Action not found."}`, true},
"in progress until timeout": {http.StatusOK, `{"action":{"id":9,"status":"in-progress"}}`, true},
} {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
client, err := binarylane.NewClientWithConfig(srv.URL, "test")
if err != nil {
t.Fatal(err)
}
sr := serverResource{bc: &BinarylaneClient{client: client}}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := sr.waitForServerAction(ctx, 1, 9); (err != nil) != tc.wantErr {
t.Fatalf("wantErr=%v, got %v", tc.wantErr, err)
}
})
}
}
@@ -1,159 +0,0 @@
package resources
import (
"cmp"
"context"
"fmt"
"slices"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// DiskKey identifies a disk across plans: the primary by being the primary, whose description
// Binary Lane assigns, and every other disk by its description. Binary Lane allows two disks to
// share a description, so they are also numbered within it: the second "data" disk configured is
// the second "data" disk on the server.
type DiskKey struct {
primary bool
description string
ordinal int
}
// DiskKeyer numbers disks as it keys them, so both sides of a match must be keyed in the same
// order: id order for the server's own disks.
type DiskKeyer map[string]int
func (k DiskKeyer) Of(primary bool, description string) DiskKey {
if primary {
return DiskKey{primary: true}
}
ordinal := k[description]
k[description]++
return DiskKey{description: description, ordinal: ordinal}
}
// DiskShrinkWarning warns that shrinking a disk may corrupt the filesystem on it.
func DiskShrinkWarning(attrPath path.Path, label string, from int64, to int64) diag.Diagnostic {
return diag.NewAttributeWarningDiagnostic(
attrPath,
fmt.Sprintf("%s shrink may corrupt the filesystem", label),
fmt.Sprintf(
"%s is being reduced from %d GB to %d GB. Binary Lane resizes the block device in place and does not touch the "+
"filesystem on it, so the filesystem(s) (and any partitions) on it must first be shrunk to fit within %d GB. "+
"Back up the server before applying, and make sure there's enough free space to shrink the filesystem to that size.",
label, from, to, to,
),
)
}
// disksPlanModifier is referenced by name from the "disks" attribute in
// scripts/data/server_disks.json, the same way disksValidator is.
//
// Terraform builds the proposed plan for a list by merging prior state into the configuration
// element by element, so removing a disk hands the disks after it the id of their predecessor.
// This plans the list from the configuration instead, taking each disk's server-assigned values
// from the disk it matches in prior state rather than from whatever sits at the same index.
type disksPlanModifier struct{}
func (m disksPlanModifier) Description(ctx context.Context) string {
return "Matches each disk with the one it corresponds to in prior state, rather than with whatever is at the same index."
}
func (m disksPlanModifier) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}
func (m disksPlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) {
// Nothing to plan when the resource is being destroyed.
if req.Plan.Raw.IsNull() {
return
}
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
// An unset `disks` is whatever Binary Lane gives the server, which isn't known until the
// server exists. Once it does, the resource's ModifyPlan plans it instead: what happens
// to the disks then depends on `disk`, the server's total, which it only resolves after
// attribute plan modifiers like this one have run.
if req.State.Raw.IsNull() {
resp.PlanValue = types.ListUnknown(DisksValue{}.Type(ctx))
}
return
}
var configured, current []DisksValue
resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &configured, true)...)
if !req.StateValue.IsNull() && !req.StateValue.IsUnknown() {
resp.Diagnostics.Append(req.StateValue.ElementsAs(ctx, &current, true)...)
}
if resp.Diagnostics.HasError() {
return
}
// Key prior state in id order, the order the server's own disks are keyed in wherever they
// are matched. serverDisksToList already leaves state in an order that agrees with it, but
// this does not have to rely on that: get it wrong and disks that share a description are
// paired with the wrong disk, and the plan promises a resize of one while the apply resizes
// another.
slices.SortStableFunc(current, func(a, b DisksValue) int {
return cmp.Compare(a.Id.ValueInt64(), b.Id.ValueInt64())
})
stateKeys := DiskKeyer{}
priorByKey := make(map[DiskKey]DisksValue, len(current))
for _, d := range current {
priorByKey[stateKeys.Of(d.Primary.ValueBool(), d.Description.ValueString())] = d
}
configKeys := DiskKeyer{}
elements := make([]attr.Value, 0, len(configured))
for _, cd := range configured {
// Additional disks may leave `primary` unset, which means false.
isPrimary := cd.Primary.ValueBool()
prior, matched := priorByKey[configKeys.Of(isPrimary, cd.Description.ValueString())]
// `id` and the primary's `description` are assigned by Binary Lane, so both are unknown
// until there is a disk in state to take them from.
id, description := types.Int64Unknown(), cd.Description
if isPrimary {
description = types.StringUnknown()
}
if matched {
if !prior.Id.IsNull() && !prior.Id.IsUnknown() {
id = prior.Id
}
if isPrimary && !prior.Description.IsNull() && !prior.Description.IsUnknown() {
description = prior.Description
}
if !cd.SizeGigabytes.IsUnknown() && cd.SizeGigabytes.ValueInt64() < prior.SizeGigabytes.ValueInt64() {
label := fmt.Sprintf("Disk %q", cd.Description.ValueString())
if isPrimary {
label = "The primary disk"
}
resp.Diagnostics.Append(DiskShrinkWarning(
req.Path, label, prior.SizeGigabytes.ValueInt64(), cd.SizeGigabytes.ValueInt64()))
}
}
disk, diskDiags := NewDisksValue(
DisksValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"id": id,
"description": description,
"primary": types.BoolValue(isPrimary),
"size_gigabytes": cd.SizeGigabytes,
},
)
resp.Diagnostics.Append(diskDiags...)
elements = append(elements, disk)
}
planned, diags := types.ListValue(DisksValue{}.Type(ctx), elements)
resp.Diagnostics.Append(diags...)
if !diags.HasError() {
resp.PlanValue = planned
}
}
@@ -1,98 +0,0 @@
package resources
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// disksValidator is referenced by name from the "disks" attribute in
// scripts/data/server_disks.json, which extend-provider.sh splices into the generated schema. It
// lives here rather than in internal/provider, which internal/resources cannot import.
type disksValidator struct{}
func (v disksValidator) Description(ctx context.Context) string {
return "Requires exactly one `disks` entry to have `primary = true` (with no `description` set) whenever `disks` is configured, and `sum(disks[*].size_gigabytes)` to not exceed `disk`."
}
func (v disksValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}
func (v disksValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}
var disks []DisksValue
resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &disks, false)...)
if resp.Diagnostics.HasError() {
return
}
primaries, primaryUnknown := 0, false
sum, sumKnown := int64(0), true
for i, d := range disks {
switch {
case d.Primary.IsUnknown():
primaryUnknown = true
case d.Primary.ValueBool():
primaries++
if !d.Description.IsNull() && !d.Description.IsUnknown() {
resp.Diagnostics.AddAttributeError(
req.Path.AtListIndex(i).AtName("description"),
"Primary disk description is server-assigned",
"The primary disk's `description` is always assigned by Binary Lane (typically \"SYSTEM\"); leave it unset on the entry with `primary = true`.",
)
}
}
if d.SizeGigabytes.IsNull() || d.SizeGigabytes.IsUnknown() {
sumKnown = false
continue
}
sum += d.SizeGigabytes.ValueInt64()
}
if resp.Diagnostics.HasError() {
return
}
if !primaryUnknown {
if primaries == 0 {
resp.Diagnostics.AddAttributeError(
req.Path,
"Missing primary disk",
"When `disks` is specified, it must fully describe every disk, including the primary — add an entry with `primary = true`.",
)
return
}
if primaries > 1 {
resp.Diagnostics.AddAttributeError(
req.Path,
"Multiple primary disks",
"Only one entry in `disks` can have `primary = true`.",
)
return
}
}
var disk types.Int32
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("disk"), &disk)...)
if resp.Diagnostics.HasError() || !sumKnown || disk.IsNull() || disk.IsUnknown() {
return
}
if sum > int64(disk.ValueInt32()) {
resp.Diagnostics.AddAttributeError(
req.Path,
"Disks exceed total disk allocation",
fmt.Sprintf(
"The sum of `disks[*].size_gigabytes` is %d GB, which exceeds `disk` (%d GB total). "+
"`sum(disks[*].size_gigabytes)` must not exceed `disk`.",
sum, disk.ValueInt32(),
),
)
}
}
-541
View File
@@ -5,11 +5,9 @@ package resources
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
@@ -96,55 +94,6 @@ func ServerResourceSchema(ctx context.Context) schema.Schema {
Description: "If true this will enable two daily backups for the server. `options.daily_backups` will override this value if provided. Setting this to false has no effect.\n",
MarkdownDescription: "If true this will enable two daily backups for the server. `options.daily_backups` will override this value if provided. Setting this to false has no effect.\n",
},
"disks": schema.ListNestedAttribute{
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"description": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.",
MarkdownDescription: "A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.",
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
"id": schema.Int64Attribute{
Computed: true,
Description: "The ID of this disk.",
MarkdownDescription: "The ID of this disk.",
},
"primary": schema.BoolAttribute{
Optional: true,
Computed: true,
Description: "If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary.",
MarkdownDescription: "If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary.",
},
"size_gigabytes": schema.Int64Attribute{
Required: true,
Description: "The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.",
MarkdownDescription: "The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.",
Validators: []validator.Int64{
int64validator.AtLeast(1),
},
},
},
CustomType: DisksType{
ObjectType: types.ObjectType{
AttrTypes: DisksValue{}.AttributeTypes(ctx),
},
},
},
Optional: true,
Computed: true,
Description: "The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane.",
MarkdownDescription: "The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane.",
PlanModifiers: []planmodifier.List{
disksPlanModifier{},
},
Validators: []validator.List{
disksValidator{},
},
},
"id": schema.Int64Attribute{
Optional: true,
Computed: true,
@@ -231,7 +180,6 @@ func ServerResourceSchema(ctx context.Context) schema.Schema {
type ServerModel struct {
AdvancedFeatures AdvancedFeaturesValue `tfsdk:"advanced_features"`
Backups types.Bool `tfsdk:"backups"`
Disks types.List `tfsdk:"disks"`
Id types.Int64 `tfsdk:"id"`
Image types.String `tfsdk:"image"`
Ipv6 types.Bool `tfsdk:"ipv6"`
@@ -1064,492 +1012,3 @@ func (v AdvancedFeaturesValue) AttributeTypes(ctx context.Context) map[string]at
"unset_uuid": basetypes.BoolType{},
}
}
var _ basetypes.ObjectTypable = DisksType{}
type DisksType struct {
basetypes.ObjectType
}
func (t DisksType) Equal(o attr.Type) bool {
other, ok := o.(DisksType)
if !ok {
return false
}
return t.ObjectType.Equal(other.ObjectType)
}
func (t DisksType) String() string {
return "DisksType"
}
func (t DisksType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) {
var diags diag.Diagnostics
attributes := in.Attributes()
descriptionAttribute, ok := attributes["description"]
if !ok {
diags.AddError(
"Attribute Missing",
`description is missing from object`)
return nil, diags
}
descriptionVal, ok := descriptionAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`description expected to be basetypes.StringValue, was: %T`, descriptionAttribute))
}
idAttribute, ok := attributes["id"]
if !ok {
diags.AddError(
"Attribute Missing",
`id is missing from object`)
return nil, diags
}
idVal, ok := idAttribute.(basetypes.Int64Value)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`id expected to be basetypes.Int64Value, was: %T`, idAttribute))
}
primaryAttribute, ok := attributes["primary"]
if !ok {
diags.AddError(
"Attribute Missing",
`primary is missing from object`)
return nil, diags
}
primaryVal, ok := primaryAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`primary expected to be basetypes.BoolValue, was: %T`, primaryAttribute))
}
sizeGigabytesAttribute, ok := attributes["size_gigabytes"]
if !ok {
diags.AddError(
"Attribute Missing",
`size_gigabytes is missing from object`)
return nil, diags
}
sizeGigabytesVal, ok := sizeGigabytesAttribute.(basetypes.Int64Value)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`size_gigabytes expected to be basetypes.Int64Value, was: %T`, sizeGigabytesAttribute))
}
if diags.HasError() {
return nil, diags
}
return DisksValue{
Description: descriptionVal,
Id: idVal,
Primary: primaryVal,
SizeGigabytes: sizeGigabytesVal,
state: attr.ValueStateKnown,
}, diags
}
func NewDisksValueNull() DisksValue {
return DisksValue{
state: attr.ValueStateNull,
}
}
func NewDisksValueUnknown() DisksValue {
return DisksValue{
state: attr.ValueStateUnknown,
}
}
func NewDisksValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (DisksValue, diag.Diagnostics) {
var diags diag.Diagnostics
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521
ctx := context.Background()
for name, attributeType := range attributeTypes {
attribute, ok := attributes[name]
if !ok {
diags.AddError(
"Missing DisksValue Attribute Value",
"While creating a DisksValue value, a missing attribute value was detected. "+
"A DisksValue must contain values for all attributes, even if null or unknown. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("DisksValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()),
)
continue
}
if !attributeType.Equal(attribute.Type(ctx)) {
diags.AddError(
"Invalid DisksValue Attribute Type",
"While creating a DisksValue value, an invalid attribute value was detected. "+
"A DisksValue must use a matching attribute type for the value. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("DisksValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+
fmt.Sprintf("DisksValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)),
)
}
}
for name := range attributes {
_, ok := attributeTypes[name]
if !ok {
diags.AddError(
"Extra DisksValue Attribute Value",
"While creating a DisksValue value, an extra attribute value was detected. "+
"A DisksValue must not contain values beyond the expected attribute types. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("Extra DisksValue Attribute Name: %s", name),
)
}
}
if diags.HasError() {
return NewDisksValueUnknown(), diags
}
descriptionAttribute, ok := attributes["description"]
if !ok {
diags.AddError(
"Attribute Missing",
`description is missing from object`)
return NewDisksValueUnknown(), diags
}
descriptionVal, ok := descriptionAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`description expected to be basetypes.StringValue, was: %T`, descriptionAttribute))
}
idAttribute, ok := attributes["id"]
if !ok {
diags.AddError(
"Attribute Missing",
`id is missing from object`)
return NewDisksValueUnknown(), diags
}
idVal, ok := idAttribute.(basetypes.Int64Value)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`id expected to be basetypes.Int64Value, was: %T`, idAttribute))
}
primaryAttribute, ok := attributes["primary"]
if !ok {
diags.AddError(
"Attribute Missing",
`primary is missing from object`)
return NewDisksValueUnknown(), diags
}
primaryVal, ok := primaryAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`primary expected to be basetypes.BoolValue, was: %T`, primaryAttribute))
}
sizeGigabytesAttribute, ok := attributes["size_gigabytes"]
if !ok {
diags.AddError(
"Attribute Missing",
`size_gigabytes is missing from object`)
return NewDisksValueUnknown(), diags
}
sizeGigabytesVal, ok := sizeGigabytesAttribute.(basetypes.Int64Value)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`size_gigabytes expected to be basetypes.Int64Value, was: %T`, sizeGigabytesAttribute))
}
if diags.HasError() {
return NewDisksValueUnknown(), diags
}
return DisksValue{
Description: descriptionVal,
Id: idVal,
Primary: primaryVal,
SizeGigabytes: sizeGigabytesVal,
state: attr.ValueStateKnown,
}, diags
}
func NewDisksValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) DisksValue {
object, diags := NewDisksValue(attributeTypes, attributes)
if diags.HasError() {
// This could potentially be added to the diag package.
diagsStrings := make([]string, 0, len(diags))
for _, diagnostic := range diags {
diagsStrings = append(diagsStrings, fmt.Sprintf(
"%s | %s | %s",
diagnostic.Severity(),
diagnostic.Summary(),
diagnostic.Detail()))
}
panic("NewDisksValueMust received error(s): " + strings.Join(diagsStrings, "\n"))
}
return object
}
func (t DisksType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
if in.Type() == nil {
return NewDisksValueNull(), nil
}
if !in.Type().Equal(t.TerraformType(ctx)) {
return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type())
}
if !in.IsKnown() {
return NewDisksValueUnknown(), nil
}
if in.IsNull() {
return NewDisksValueNull(), nil
}
attributes := map[string]attr.Value{}
val := map[string]tftypes.Value{}
err := in.As(&val)
if err != nil {
return nil, err
}
for k, v := range val {
a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v)
if err != nil {
return nil, err
}
attributes[k] = a
}
return NewDisksValueMust(DisksValue{}.AttributeTypes(ctx), attributes), nil
}
func (t DisksType) ValueType(ctx context.Context) attr.Value {
return DisksValue{}
}
var _ basetypes.ObjectValuable = DisksValue{}
type DisksValue struct {
Description basetypes.StringValue `tfsdk:"description"`
Id basetypes.Int64Value `tfsdk:"id"`
Primary basetypes.BoolValue `tfsdk:"primary"`
SizeGigabytes basetypes.Int64Value `tfsdk:"size_gigabytes"`
state attr.ValueState
}
func (v DisksValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) {
attrTypes := make(map[string]tftypes.Type, 4)
var val tftypes.Value
var err error
attrTypes["description"] = basetypes.StringType{}.TerraformType(ctx)
attrTypes["id"] = basetypes.Int64Type{}.TerraformType(ctx)
attrTypes["primary"] = basetypes.BoolType{}.TerraformType(ctx)
attrTypes["size_gigabytes"] = basetypes.Int64Type{}.TerraformType(ctx)
objectType := tftypes.Object{AttributeTypes: attrTypes}
switch v.state {
case attr.ValueStateKnown:
vals := make(map[string]tftypes.Value, 4)
val, err = v.Description.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["description"] = val
val, err = v.Id.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["id"] = val
val, err = v.Primary.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["primary"] = val
val, err = v.SizeGigabytes.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["size_gigabytes"] = val
if err := tftypes.ValidateValue(objectType, vals); err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
return tftypes.NewValue(objectType, vals), nil
case attr.ValueStateNull:
return tftypes.NewValue(objectType, nil), nil
case attr.ValueStateUnknown:
return tftypes.NewValue(objectType, tftypes.UnknownValue), nil
default:
panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state))
}
}
func (v DisksValue) IsNull() bool {
return v.state == attr.ValueStateNull
}
func (v DisksValue) IsUnknown() bool {
return v.state == attr.ValueStateUnknown
}
func (v DisksValue) String() string {
return "DisksValue"
}
func (v DisksValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) {
var diags diag.Diagnostics
attributeTypes := map[string]attr.Type{
"description": basetypes.StringType{},
"id": basetypes.Int64Type{},
"primary": basetypes.BoolType{},
"size_gigabytes": basetypes.Int64Type{},
}
if v.IsNull() {
return types.ObjectNull(attributeTypes), diags
}
if v.IsUnknown() {
return types.ObjectUnknown(attributeTypes), diags
}
objVal, diags := types.ObjectValue(
attributeTypes,
map[string]attr.Value{
"description": v.Description,
"id": v.Id,
"primary": v.Primary,
"size_gigabytes": v.SizeGigabytes,
})
return objVal, diags
}
func (v DisksValue) Equal(o attr.Value) bool {
other, ok := o.(DisksValue)
if !ok {
return false
}
if v.state != other.state {
return false
}
if v.state != attr.ValueStateKnown {
return true
}
if !v.Description.Equal(other.Description) {
return false
}
if !v.Id.Equal(other.Id) {
return false
}
if !v.Primary.Equal(other.Primary) {
return false
}
if !v.SizeGigabytes.Equal(other.SizeGigabytes) {
return false
}
return true
}
func (v DisksValue) Type(ctx context.Context) attr.Type {
return DisksType{
basetypes.ObjectType{
AttrTypes: v.AttributeTypes(ctx),
},
}
}
func (v DisksValue) AttributeTypes(ctx context.Context) map[string]attr.Type {
return map[string]attr.Type{
"description": basetypes.StringType{},
"id": basetypes.Int64Type{},
"primary": basetypes.BoolType{},
"size_gigabytes": basetypes.Int64Type{},
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
flag.Parse()
opts := providerserver.ServeOpts{
Address: "hashicorp.com/oscarhermoso/binarylane",
Address: "git.unkin.net/unkin/binarylane",
Debug: debug,
}
+72 -92
View File
@@ -8,7 +8,7 @@
"url": "https://support.binarylane.com.au/support/home",
"email": "support@binarylane.com.au"
},
"version": "0.40.0",
"version": "0.39.1",
"x-logo": {
"url": "https://www.binarylane.com.au/res/images/binarylane/logo.png",
"backgroundColor": "#000000",
@@ -111,12 +111,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -277,12 +277,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -401,12 +401,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -517,12 +517,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -730,12 +730,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -1029,12 +1029,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -1292,12 +1292,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -1492,14 +1492,6 @@
],
"summary": "List all Load Balancers",
"parameters": [
{
"name": "name",
"in": "query",
"description": "Providing a name restricts the results to load balancers with this name (case insensitive).",
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
@@ -1516,12 +1508,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -1542,7 +1534,7 @@
"x-codeSamples": [
{
"lang": "Curl",
"source": "curl -X GET \"https://api.binarylane.com.au/v2/load_balancers?name=${name}&page=${page}&per_page=${per_page}\" \\\n-H \"Authorization: Bearer ${APITOKEN}\""
"source": "curl -X GET \"https://api.binarylane.com.au/v2/load_balancers?page=${page}&per_page=${per_page}\" \\\n-H \"Authorization: Bearer ${APITOKEN}\""
}
],
"x-cli-command": "load-balancer list"
@@ -1816,12 +1808,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -1872,12 +1864,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2064,12 +2056,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2130,12 +2122,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2503,12 +2495,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2737,12 +2729,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2855,12 +2847,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -2922,12 +2914,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3056,12 +3048,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3209,12 +3201,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3303,12 +3295,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3376,12 +3368,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3611,12 +3603,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -3729,12 +3721,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"schema": {
"maximum": 200,
"minimum": 0,
"minimum": 1,
"type": "integer",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"description": "The number of results to show per page.",
"format": "int32",
"default": 20
}
@@ -8006,33 +7998,21 @@
"id": {
"type": "integer",
"description": "The ID of this disk.",
"format": "int64",
"x-oapi-codegen-extra-tags": {
"tfsdk": "id"
}
"format": "int64"
},
"size_gigabytes": {
"type": "number",
"description": "The size of the disk in GB.",
"format": "double",
"x-oapi-codegen-extra-tags": {
"tfsdk": "size_gigabytes"
}
"format": "double"
},
"description": {
"type": "string",
"description": "A description of this disk.",
"nullable": true,
"x-oapi-codegen-extra-tags": {
"tfsdk": "description"
}
"nullable": true
},
"primary": {
"type": "boolean",
"description": "A primary disk is treated differently from other disks.",
"x-oapi-codegen-extra-tags": {
"tfsdk": "primary"
}
"description": "A primary disk is treated differently from other disks."
}
}
},
-82
View File
@@ -1037,88 +1037,6 @@
}
]
}
},
{
"name": "disks",
"list_nested": {
"computed_optional_required": "computed_optional",
"description": "The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane.",
"nested_object": {
"attributes": [
{
"name": "id",
"int64": {
"computed_optional_required": "computed",
"description": "The ID of this disk."
}
},
{
"name": "description",
"string": {
"computed_optional_required": "computed_optional",
"description": "A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.",
"validators": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
}
],
"schema_definition": "stringvalidator.LengthAtLeast(1)"
}
}
]
}
},
{
"name": "primary",
"bool": {
"computed_optional_required": "computed_optional",
"description": "If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary."
}
},
{
"name": "size_gigabytes",
"int64": {
"computed_optional_required": "required",
"description": "The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.",
"validators": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
}
],
"schema_definition": "int64validator.AtLeast(1)"
}
}
]
}
}
]
},
"validators": [
{
"custom": {
"schema_definition": "disksValidator{}"
}
}
],
"plan_modifiers": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
}
],
"schema_definition": "disksPlanModifier{}"
}
}
]
}
}
]
}
-82
View File
@@ -1,82 +0,0 @@
{
"name": "disks",
"list_nested": {
"computed_optional_required": "computed_optional",
"description": "The disks attached to this server. Leave null to let Binary Lane manage the server's disks. If specified, every disk must be listed, including the primary: exactly one entry must have `primary = true`, and its `id` and `description` are assigned by Binary Lane.",
"nested_object": {
"attributes": [
{
"name": "id",
"int64": {
"computed_optional_required": "computed",
"description": "The ID of this disk."
}
},
{
"name": "description",
"string": {
"computed_optional_required": "computed_optional",
"description": "A label for this disk. Assigned by Binary Lane for the primary disk, so must be left null when `primary = true`. Changing this will destroy the disk and create a new one, losing any data on it. Disks that share a description are matched in the order they are listed.",
"validators": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
}
],
"schema_definition": "stringvalidator.LengthAtLeast(1)"
}
}
]
}
},
{
"name": "primary",
"bool": {
"computed_optional_required": "computed_optional",
"description": "If `true` this is the primary disk, which the operating system is installed on. Exactly one disk must be the primary."
}
},
{
"name": "size_gigabytes",
"int64": {
"computed_optional_required": "required",
"description": "The size of the disk in GB. The sum across all disks cannot exceed `disk`, and any remainder is left unallocated.",
"validators": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
}
],
"schema_definition": "int64validator.AtLeast(1)"
}
}
]
}
}
]
},
"validators": [
{
"custom": {
"schema_definition": "disksValidator{}"
}
}
],
"plan_modifiers": [
{
"custom": {
"imports": [
{
"path": "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
}
],
"schema_definition": "disksPlanModifier{}"
}
}
]
}
}
-3
View File
@@ -9,9 +9,6 @@ cat <<<$(jq --tab '. + {"$schema": "https://raw.githubusercontent.com/hashicorp/
ADVANCED_FEATURES_CONFIG=$(dirname "$0")/data/server_advanced_features.json
cat <<<$(jq --tab --slurpfile adv_feat_cfg $ADVANCED_FEATURES_CONFIG '.resources[1].schema.attributes |= . + $adv_feat_cfg' $GENERATOR_CONFIG) >$GENERATOR_CONFIG
DISKS_CONFIG=$(dirname "$0")/data/server_disks.json
cat <<<$(jq --tab --slurpfile disks_cfg $DISKS_CONFIG '.resources[1].schema.attributes |= . + $disks_cfg' $GENERATOR_CONFIG) >$GENERATOR_CONFIG
# Use set instead of list for server_ids in load_balancer resource
jq --tab '
.resources |= map(
-6
View File
@@ -37,12 +37,6 @@ cat <<<$(jq 'del(.paths."/images/{image_id}")' $OPENAPI_FILE) >$OPENAPI_FILE
# Add x-oapi-codegen-extra-tags so structs can be reflected
## Disk
cat <<<$(jq '.components.schemas.Disk.properties.id += {"x-oapi-codegen-extra-tags": {"tfsdk": "id"}}' $OPENAPI_FILE) >$OPENAPI_FILE
cat <<<$(jq '.components.schemas.Disk.properties.description += {"x-oapi-codegen-extra-tags": {"tfsdk": "description"}}' $OPENAPI_FILE) >$OPENAPI_FILE
cat <<<$(jq '.components.schemas.Disk.properties.primary += {"x-oapi-codegen-extra-tags": {"tfsdk": "primary"}}' $OPENAPI_FILE) >$OPENAPI_FILE
cat <<<$(jq '.components.schemas.Disk.properties.size_gigabytes += {"x-oapi-codegen-extra-tags": {"tfsdk": "size_gigabytes"}}' $OPENAPI_FILE) >$OPENAPI_FILE
## RouteEntryRequest
cat <<<$(jq '.components.schemas.RouteEntryRequest.properties.destination += {"x-oapi-codegen-extra-tags": {"tfsdk": "destination"}}' $OPENAPI_FILE) >$OPENAPI_FILE
cat <<<$(jq '.components.schemas.RouteEntryRequest.properties.description += {"x-oapi-codegen-extra-tags": {"tfsdk": "description"}}' $OPENAPI_FILE) >$OPENAPI_FILE