Compare commits

...

4 Commits

Author SHA1 Message Date
Oscar Hermoso 725fa71248 💽 Add disks attribute to server resource & data source
Adds `disks` to `binarylane_server` and its data source, so a server's storage
can be split across several disks. Leave it unset and Binary Lane keeps managing
a single disk sized by `disk`; set it and it must describe every disk, including
the primary.

- The schema is generated the same way as `advanced_features`:
  scripts/data/server_disks.json is spliced into provider_code_spec.json by
  extend-provider.sh, with the list validator in
  internal/resources/server_disks_validators.go.
- `disk` remains the server's total storage. sum(disks[*].size_gigabytes) cannot
  exceed it, and any remainder is left unallocated.
- Create carves the additional disks out of the primary, which Binary Lane sizes
  to the whole total. Update frees space before the server's resize action and
  claims it afterwards, persisting what was actually done if a step fails.
- Disks are matched across plans by description, or by being the primary, and are
  numbered within a description so a server can have two disks sharing one.
- `disks` is returned in the order the configuration lists them, since Terraform
  requires a list's elements to stay where they were planned.
- Shrinking a disk warns at plan time, as does attaching additional disks
  alongside `user_data` that cloud-init may still be running.

Also bumps the OpenAPI schema to v0.39.2, and fixes two pre-existing bugs found
running the acceptance suite against real infrastructure: `advanced_features`
and `public_ipv6_addresses` weren't carried forward from state on a no-op
replan, so they planned as unknown on every apply.

🧪 Assert disks inline, and cover ordering in the acceptance tests

Drops the checkServerDisks/expectedDisk helper for the inline
TestCheckResourceAttr and TestCheckResourceAttrSet calls every other check in
this file uses.

Replaces the TestServerDisksToList unit test with the equivalent assertions in
TestServerResourceCreateWithDisks, which already reorders the disks of a real
server: the resource keeps the order its configuration lists them in, while the
data source, which has no order of its own, lists the primary disk first and the
rest by id. Both disks sharing the description "data" cover the duplicate case.

🧪 Pin disk ids across a list shift

Removing a disk shifts every disk after it down a position, which is where
terraform-plugin-framework#720 bites: Terraform merges prior state into the
proposed plan positionally, so the disk that moved up would inherit the id of
the one that was deleted, and the provider would resize the wrong disk.

planServerDisks matches by description (and by being the primary) instead of by
index, so this only asserts what should already hold: "alpha" and "beta" keep
the disks they had when "zeta" is removed from in front of them.

🏗️ Plan `disks` from a plan modifier on the attribute

Moves the planning of a configured `disks` out of the resource's ModifyPlan and
onto the attribute itself, as disksPlanModifier in internal/resources — the same
place, and declared the same way in scripts/data/server_disks.json, as
disksValidator.

Behaviour is unchanged: Terraform builds a list's proposed plan by merging prior
state in element by element, so removing a disk hands the disks after it the id
of their predecessor. The modifier plans the list from the configuration
instead, taking each disk's server-assigned values from the disk it matches in
prior state. DiskKey/DiskKeyer and DiskShrinkWarning move with it and are now
exported, since ordering and reconciliation match disks by the same rule.

What stays in ModifyPlan is the case `disks` is not configured, where what
happens to the disks depends on `disk` — the server's total, which ModifyPlan
resolves from state a few lines earlier, after attribute plan modifiers have
already run.

🏗️ Plan an unset `disks` on create from the plan modifier too

The creation case needs nothing but the absence of prior state, so it moves next
to the rest of the disk planning.

The remaining case — `disks` left out of the configuration on an existing server
— stays in ModifyPlan, and can't move: it depends on `disk`, the server's total,
which ModifyPlan resolves from state only after attribute plan modifiers have
run. Reading `disk` from the plan inside the modifier plans the disks as unknown
on every no-op replan, which TestServerResourceRename catches:

    ~ disks = [ - { id = 1 ... } ] -> (known after apply)
    Plan: 0 to add, 1 to change, 0 to destroy.

Working the total out from the configuration inside the modifier would restate a
rule that already lives in ModifyPlan, so it stays where the value it needs is.

🧪 Try `disks` as a SetNestedAttribute

A set has no order, so everything that existed to keep a list's elements where
Terraform planned them goes away: serverDisksToList's ranking and its `order`
argument threaded through five call sites become a three-line
types.SetValueFrom, and a configuration can list its disks however it likes.

Verified against the live API: create with duplicate descriptions, update
(resize, add, remove), dropping `disks`, reordering, and resizing one of two
disks that share a description — the case a set is least equipped for, since it
has no positions to match duplicates by.

Costs: a resize renders as a remove/add pair rather than an in-place change (the
id is preserved in both halves), and tests need checkDiskId to find a disk,
since a set has no index to address one with.

🪞 Reconcile disks as binarylane.Disk, not as Terraform values

binarylane.Disk carries tfsdk tags for exactly the attributes `disks` has, so a
configured set reflects straight into it. splitDisks now hands Create and Update
the API's own type, which is what the disks they compare against already are:
the comparison that read `disk.SizeGigabytes.ValueInt64() <
int64(other.SizeGigabytes)` now reads `disk.SizeGigabytes <
other.SizeGigabytes`. Eight value-unwrapping conversions go with it.

The plan modifier keeps building Terraform values by hand, since the attributes
Binary Lane assigns are still unknown while it runs.

🧪 Restore `disks` to ListNestedAttribute

Co-Authored-By: Marais Rossouw <maraisr@users.noreply.github.com>
2026-08-30 18:12:02 +08:00
Marais Rossouw 6970691546 chore: add warning for user_data and disks 2026-08-11 17:43:07 +10:00
Marais Rossouw d8ca224107 chore: we should do the disk size validator across the total 2026-08-11 17:43:07 +10:00
Marais Rossouw d168435ed6 feat: add support for additional disks 2026-08-11 17:43:07 +10:00
20 changed files with 2006 additions and 184 deletions
+13 -1
View File
@@ -29,10 +29,11 @@ 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:
@@ -71,3 +72,14 @@ 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.
+27 -1
View File
@@ -20,6 +20,14 @@ 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")
@@ -40,10 +48,11 @@ 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
@@ -91,6 +100,23 @@ 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`
@@ -5,6 +5,14 @@ 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")
+16
View File
@@ -4106,6 +4106,22 @@ 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 {
+29 -26
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"`
Description *string `json:"description,omitempty" tfsdk:"description"`
// Id The ID of this disk.
Id int64 `json:"id"`
Id int64 `json:"id" tfsdk:"id"`
// Primary A primary disk is treated differently from other disks.
Primary bool `json:"primary"`
Primary bool `json:"primary" tfsdk:"primary"`
// SizeGigabytes The size of the disk in GB.
SizeGigabytes float64 `json:"size_gigabytes"`
SizeGigabytes float64 `json:"size_gigabytes" tfsdk:"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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4246,10 +4246,13 @@ 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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4276,7 +4279,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4285,7 +4288,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4315,7 +4318,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4342,7 +4345,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4360,7 +4363,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4372,7 +4375,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4384,7 +4387,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4393,7 +4396,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4402,7 +4405,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4417,7 +4420,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4426,7 +4429,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4435,7 +4438,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4450,7 +4453,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
@@ -4480,7 +4483,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.
// PerPage The number of results to show per page. Use 0 to return only the total count.
PerPage *int32 `form:"per_page,omitempty" json:"per_page,omitempty"`
}
+31 -46
View File
@@ -140,11 +140,10 @@ 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) {
@@ -404,49 +403,35 @@ func (r *loadBalancerResource) ImportState(
}
// Import by name
var page int32 = 1
perPage := int32(200)
var loadBalancer binarylane.LoadBalancer
var nextPage bool = true
for nextPage { // Need to paginate because the API does not support filtering by name
params := binarylane.GetLoadBalancersParams{
Page: &page,
PerPage: &perPage,
}
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++
params := binarylane.GetLoadBalancersParams{
Name: &req.ID,
}
diags := resp.State.SetAttribute(ctx, path.Root("id"), loadBalancer.Id)
resp.Diagnostics.Append(diags...)
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
}
for _, lb := range lbResp.JSON200.LoadBalancers {
if lb.Name == req.ID {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), lb.Id)...)
return
}
}
resp.Diagnostics.AddError(
"Load balancer not found",
fmt.Sprintf("No load balancer named %q was found to import.", req.ID),
)
}
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 = "per"
region = "` + testRegion + `"
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 = "per"
region = "` + testRegion + `"
image = "debian-12"
size = "std-min"
password = "` + password + `"
+18
View File
@@ -15,6 +15,24 @@ 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,6 +118,8 @@ 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)
@@ -20,7 +20,7 @@ func TestServerFirewallRulesResource(t *testing.T) {
resource "binarylane_server" "test" {
name = "tf-test-server-fw-rules"
region = "per"
region = "` + testRegion + `"
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 = "per"
region = "` + testRegion + `"
image = "debian-12"
size = "std-min"
password = "` + password + `"
+443 -15
View File
@@ -1,8 +1,10 @@
package provider
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
@@ -355,8 +357,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
@@ -423,6 +425,21 @@ 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()
@@ -451,6 +468,8 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
}
if req.State.Raw.IsNull() {
warnDisksMayRaceUserData()
// Creation plan, no further modification needed
return
}
@@ -498,6 +517,7 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
strings.Join(attrsRequiringRebuild, ", "),
),
)
warnDisksMayRaceUserData()
}
if !plan.Ipv6.Equal(state.Ipv6) {
@@ -508,6 +528,11 @@ 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
@@ -518,6 +543,32 @@ 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 {
@@ -579,6 +630,11 @@ 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
@@ -627,6 +683,14 @@ 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()
}
@@ -691,6 +755,8 @@ 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
@@ -770,6 +836,25 @@ 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)
@@ -817,7 +902,6 @@ 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
@@ -960,28 +1044,68 @@ 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
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) ||
memoryChanged := !plan.Memory.IsNull() && !plan.Memory.IsUnknown() && !plan.Memory.Equal(state.Memory)
if sizeChanged || memoryChanged ||
!plan.Image.Equal(state.Image) ||
!plan.PublicIpv4Count.Equal(state.PublicIpv4Count) {
!plan.PublicIpv4Count.Equal(state.PublicIpv4Count) ||
diskChanged {
resizeReq := &binarylane.PostServersServerIdActionsResizeJSONRequestBody{
Type: "resize",
Options: &binarylane.ChangeSizeOptionsRequest{},
}
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 sizeChanged || memoryChanged || diskChanged {
if sizeChanged {
resizeReq.Size = plan.Size.ValueStringPointer()
}
if !plan.Memory.IsUnknown() && !plan.Memory.IsNull() {
resizeReq.Options.Memory = plan.Memory.ValueInt32Pointer()
}
if !plan.Disk.IsNull() && !plan.Disk.IsUnknown() {
resizeReq.Options.Disk = plan.Disk.ValueInt32Pointer()
if planDiskKnown {
disk := plan.Disk.ValueInt32()
resizeReq.Options.Disk = &disk
}
state.Size = plan.Size
state.Memory = plan.Memory
@@ -1042,6 +1166,9 @@ 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
}
@@ -1053,6 +1180,18 @@ 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(
@@ -1174,7 +1313,6 @@ 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
@@ -1508,6 +1646,9 @@ 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)
@@ -1706,3 +1847,290 @@ 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
}
+352 -19
View File
@@ -23,6 +23,10 @@ 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{
@@ -49,7 +53,7 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
memory = 1152
@@ -77,11 +81,16 @@ 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", "per"),
resource.TestCheckResourceAttr("binarylane_server.test", "region", testRegion),
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"),
@@ -114,7 +123,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", "per"),
resource.TestCheckResourceAttr("data.binarylane_server.test", "region", testRegion),
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"),
@@ -125,6 +134,11 @@ 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"),
@@ -180,10 +194,16 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource-2"
region = "per"
region = "` + testRegion + `"
image = "debian-12"
size = "std-1vcpu"
disk = "45"
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 },
]
password = "` + password1 + `"
vpc_id = null
public_ipv4_count = 0
@@ -207,9 +227,34 @@ EOT
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("binarylane_server.test", "name", "tf-test-server-resource-2"),
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", "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", "public_ipv4_count", "0"),
resource.TestCheckResourceAttr("binarylane_server.test", "public_ipv4_addresses.#", "0"),
resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-12"),
@@ -254,10 +299,16 @@ resource "binarylane_ssh_key" "updated" {
resource "binarylane_server" "test" {
name = "tf-test-server-resource-2"
region = "per"
region = "` + testRegion + `"
image = "debian-12"
size = "std-1vcpu"
disk = "45"
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 },
]
password = "` + password2 + `"
vpc_id = null
public_ipv4_count = 0
@@ -278,6 +329,288 @@ 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"),
),
},
},
@@ -297,7 +630,7 @@ func TestServerResourceRename(t *testing.T) {
resource "binarylane_server" "test" {
name = "tf-test-server-rename-1"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -310,7 +643,7 @@ resource "binarylane_server" "test" {
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-rename-2"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -319,7 +652,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", "per"),
resource.TestCheckResourceAttr("binarylane_server.test", "region", testRegion),
resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-11"),
resource.TestCheckResourceAttr("binarylane_server.test", "size", "std-min"),
resource.TestCheckResourceAttr("binarylane_server.test", "password", password),
@@ -341,7 +674,7 @@ func TestServerVpcIpv4Change(t *testing.T) {
Config: providerConfig + `
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -367,7 +700,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -407,7 +740,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -447,7 +780,7 @@ resource "binarylane_vpc" "test2" {
resource "binarylane_server" "test" {
name = "tf-test-server-vpcipv4-1"
region = "per"
region = "` + testRegion + `"
image = "debian-11"
size = "std-min"
public_ipv4_count = 0
@@ -0,0 +1,159 @@
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
}
}
@@ -0,0 +1,98 @@
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,9 +5,11 @@ 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"
@@ -94,6 +96,55 @@ 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,
@@ -180,6 +231,7 @@ 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"`
@@ -1012,3 +1064,492 @@ 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{},
}
}
+92 -72
View File
@@ -8,7 +8,7 @@
"url": "https://support.binarylane.com.au/support/home",
"email": "support@binarylane.com.au"
},
"version": "0.39.1",
"version": "0.40.0",
"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.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -277,12 +277,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -401,12 +401,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -517,12 +517,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -730,12 +730,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -1029,12 +1029,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -1292,12 +1292,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -1492,6 +1492,14 @@
],
"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",
@@ -1508,12 +1516,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -1534,7 +1542,7 @@
"x-codeSamples": [
{
"lang": "Curl",
"source": "curl -X GET \"https://api.binarylane.com.au/v2/load_balancers?page=${page}&per_page=${per_page}\" \\\n-H \"Authorization: Bearer ${APITOKEN}\""
"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}\""
}
],
"x-cli-command": "load-balancer list"
@@ -1808,12 +1816,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -1864,12 +1872,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2056,12 +2064,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2122,12 +2130,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2495,12 +2503,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2729,12 +2737,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2847,12 +2855,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -2914,12 +2922,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3048,12 +3056,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3201,12 +3209,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3295,12 +3303,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3368,12 +3376,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3603,12 +3611,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -3721,12 +3729,12 @@
{
"name": "per_page",
"in": "query",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"schema": {
"maximum": 200,
"minimum": 1,
"minimum": 0,
"type": "integer",
"description": "The number of results to show per page.",
"description": "The number of results to show per page. Use 0 to return only the total count.",
"format": "int32",
"default": 20
}
@@ -7998,21 +8006,33 @@
"id": {
"type": "integer",
"description": "The ID of this disk.",
"format": "int64"
"format": "int64",
"x-oapi-codegen-extra-tags": {
"tfsdk": "id"
}
},
"size_gigabytes": {
"type": "number",
"description": "The size of the disk in GB.",
"format": "double"
"format": "double",
"x-oapi-codegen-extra-tags": {
"tfsdk": "size_gigabytes"
}
},
"description": {
"type": "string",
"description": "A description of this disk.",
"nullable": true
"nullable": true,
"x-oapi-codegen-extra-tags": {
"tfsdk": "description"
}
},
"primary": {
"type": "boolean",
"description": "A primary disk is treated differently from other disks."
"description": "A primary disk is treated differently from other disks.",
"x-oapi-codegen-extra-tags": {
"tfsdk": "primary"
}
}
}
},
+82
View File
@@ -1037,6 +1037,88 @@
}
]
}
},
{
"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
@@ -0,0 +1,82 @@
{
"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,6 +9,9 @@ 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,6 +37,12 @@ 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