diff --git a/docs/data-sources/server.md b/docs/data-sources/server.md
index c83b1ff..50ed178 100644
--- a/docs/data-sources/server.md
+++ b/docs/data-sources/server.md
@@ -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 size of the primary disk in GB for this server. Leave null to accept the default for the size. When `disks` is also specified, the server's total allocated storage is `disk + sum(disks.size_gigabytes)` and this value refers only to the primary disk. 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) A list of additional disks to attach to the server, on top of the primary disk. The server's total allocated storage will be `disk + sum(disks.size_gigabytes)`. Each disk is identified by its `name`, which is sent to the API as the disk description. Renaming a disk will cause it to be destroyed and re-created (data loss); resizing a disk is supported in place. (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,13 @@ Read-Only:
- `qemu_guest_agent` (Boolean)
- `uefi_boot` (Boolean)
- `unset_uuid` (Boolean)
+
+
+
+### Nested Schema for `disks`
+
+Read-Only:
+
+- `id` (Number) The server-assigned ID of this disk.
+- `name` (String) A label for the disk. Sent to the API as the disk description. Must be unique within the `disks` list.
+- `size_gigabytes` (Number) The size of the additional disk in GB.
diff --git a/docs/resources/server.md b/docs/resources/server.md
index 43b1124..80dfd2d 100644
--- a/docs/resources/server.md
+++ b/docs/resources/server.md
@@ -20,6 +20,15 @@ resource "binarylane_server" "example" {
size = "std-min" # 1 VPCU, 1 GB Memory, 20 GB NVME Storage, 1000 GB Data Transfer
public_ipv4_count = 1
+ # When `disks` is set, `disk` is the primary disk size and the server's total allocated
+ # storage is `disk + sum(disks.size_gigabytes)`. Each additional disk is matched by name
+ # across plans; renaming a disk will destroy and re-create it (data loss).
+ # disk = 20
+ # disks = [
+ # { name = "data1", size_gigabytes = 10 },
+ # { name = "data2", size_gigabytes = 20 },
+ # ]
+
# 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 +49,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 size of the primary disk in GB for this server. Leave null to accept the default for the size. When `disks` is also specified, the server's total allocated storage is `disk + sum(disks.size_gigabytes)` and this value refers only to the primary disk. 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) A list of additional disks to attach to the server, on top of the primary disk. The server's total allocated storage will be `disk + sum(disks.size_gigabytes)`. Each disk is identified by its `name`, which is sent to the API as the disk description. Renaming a disk will cause it to be destroyed and re-created (data loss); resizing a disk is supported in place. (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 +101,19 @@ Read-Only:
- `uefi_boot` (Boolean) When this option is enabled the Cloud Server will use UEFI instead of legacy PC BIOS.
+
+### Nested Schema for `disks`
+
+Required:
+
+- `name` (String) A label for the disk. Sent to the API as the disk description. Must be unique within the `disks` list.
+- `size_gigabytes` (Number) The size of the additional disk in GB.
+
+Read-Only:
+
+- `id` (Number) The server-assigned ID of this disk.
+
+
### Nested Schema for `timeouts`
diff --git a/examples/resources/binarylane_server/resource.tf b/examples/resources/binarylane_server/resource.tf
index 5f9f396..1a762a9 100644
--- a/examples/resources/binarylane_server/resource.tf
+++ b/examples/resources/binarylane_server/resource.tf
@@ -5,6 +5,15 @@ resource "binarylane_server" "example" {
size = "std-min" # 1 VPCU, 1 GB Memory, 20 GB NVME Storage, 1000 GB Data Transfer
public_ipv4_count = 1
+ # When `disks` is set, `disk` is the primary disk size and the server's total allocated
+ # storage is `disk + sum(disks.size_gigabytes)`. Each additional disk is matched by name
+ # across plans; renaming a disk will destroy and re-create it (data loss).
+ # disk = 20
+ # disks = [
+ # { name = "data1", size_gigabytes = 10 },
+ # { name = "data2", size_gigabytes = 20 },
+ # ]
+
# 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")
diff --git a/internal/provider/server_data_source.go b/internal/provider/server_data_source.go
index 0a20477..64df82f 100644
--- a/internal/provider/server_data_source.go
+++ b/internal/provider/server_data_source.go
@@ -37,9 +37,28 @@ type serverDataModel struct {
Permalink types.String `tfsdk:"permalink"`
Memory types.Int32 `tfsdk:"memory"`
Disk types.Int32 `tfsdk:"disk"`
+ Disks types.List `tfsdk:"disks"`
SourceAndDestinationCheck types.Bool `tfsdk:"source_and_destination_check"`
}
+type serverDiskModel struct {
+ Id types.Int64 `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ SizeGigabytes types.Int32 `tfsdk:"size_gigabytes"`
+}
+
+func serverDiskAttrTypes() map[string]attr.Type {
+ return map[string]attr.Type{
+ "id": types.Int64Type,
+ "name": types.StringType,
+ "size_gigabytes": types.Int32Type,
+ }
+}
+
+func serverDiskObjectType() types.ObjectType {
+ return types.ObjectType{AttrTypes: serverDiskAttrTypes()}
+}
+
func (d *serverDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
@@ -117,7 +136,7 @@ func (d *serverDataSource) Read(ctx context.Context, req datasource.ReadRequest,
data.VpcId = types.Int64PointerValue(serverResp.JSON200.Server.VpcId)
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.Disk, data.Disks = readServerDisks(ctx, serverResp.JSON200.Server.Disks, data.Disks, &resp.Diagnostics)
data.SourceAndDestinationCheck = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck)
data.SeparatePrivateNetworkInterface = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SeparatePrivateNetworkInterface)
diff --git a/internal/provider/server_resource.go b/internal/provider/server_resource.go
index cab4103..c4bdc5e 100644
--- a/internal/provider/server_resource.go
+++ b/internal/provider/server_resource.go
@@ -355,7 +355,9 @@ 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`
+ diskDescription := "The size of the primary disk in GB for this server. Leave null to accept the default for the size. " +
+ "When `disks` is also specified, the server's total allocated storage is `disk + sum(disks.size_gigabytes)` and this value " +
+ "refers only to the primary disk. "
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:
@@ -367,7 +369,6 @@ func serverSchema(ctx context.Context) schema.Schema {
Description: diskDescription + diskValidValues,
MarkdownDescription: diskDescription + diskValidValuesMarkdown,
Optional: true,
- Required: false,
Computed: true,
Validators: []validator.Int32{
int32validator.AtLeast(20),
@@ -377,6 +378,50 @@ func serverSchema(ctx context.Context) schema.Schema {
},
}
+ disksDescription := "A list of additional disks to attach to the server, on top of the primary disk. " +
+ "The server's total allocated storage will be `disk + sum(disks.size_gigabytes)`. " +
+ "Each disk is identified by its `name`, which is sent to the API as the disk description. " +
+ "Renaming a disk will cause it to be destroyed and re-created (data loss); resizing a disk is supported in place."
+ s.Attributes["disks"] = schema.ListNestedAttribute{
+ Description: disksDescription,
+ MarkdownDescription: disksDescription,
+ Optional: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.Int64Attribute{
+ Description: "The server-assigned ID of this disk.",
+ MarkdownDescription: "The server-assigned ID of this disk.",
+ Computed: true,
+ },
+ "name": schema.StringAttribute{
+ Description: "A label for the disk. Sent to the API as the disk description. Must be unique within the `disks` list.",
+ MarkdownDescription: "A label for the disk. Sent to the API as the disk description. Must be unique within the `disks` list.",
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.LengthAtLeast(1),
+ },
+ },
+ "size_gigabytes": schema.Int32Attribute{
+ Description: "The size of the additional disk in GB.",
+ MarkdownDescription: "The size of the additional disk in GB.",
+ Required: true,
+ Validators: []validator.Int32{
+ int32validator.AtLeast(1),
+ },
+ },
+ },
+ },
+ Validators: []validator.List{
+ disksRequiresPrimaryDiskValidator{},
+ uniqueDiskNamesValidator{},
+ totalDiskFollowsRulesValidator{Rules: []MultipleOfValidator{
+ {Multiple: 5},
+ {Multiple: 10, RangeFrom: 60, RangeTo: 200},
+ {Multiple: 100, RangeFrom: 200},
+ }},
+ },
+ }
+
s.Attributes["timeouts"] = timeouts.Attributes(ctx, timeouts.Opts{
Create: true,
Update: true,
@@ -450,6 +495,50 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...)
}
+ // Carry the computed `id` of each plan disk over from prior state by name match.
+ // terraform-plugin-framework plans Computed-only attributes inside newly-added list
+ // elements as null, which would otherwise fail the "provider produced inconsistent
+ // result" check after apply when we populate the id from the API response.
+ if !plan.Disks.IsNull() && !plan.Disks.IsUnknown() {
+ stateIdsByName := map[string]types.Int64{}
+ if !req.State.Raw.IsNull() {
+ var stateForDisks serverResourceModel
+ if !req.State.Get(ctx, &stateForDisks).HasError() {
+ stateDiskList, sdDiags := elementsAsServerDisks(ctx, stateForDisks.Disks)
+ if !sdDiags.HasError() {
+ for _, sd := range stateDiskList {
+ stateIdsByName[sd.Name.ValueString()] = sd.Id
+ }
+ }
+ }
+ }
+
+ planDiskList, diskDiags := elementsAsServerDisks(ctx, plan.Disks)
+ resp.Diagnostics.Append(diskDiags...)
+ if !diskDiags.HasError() {
+ elements := make([]attr.Value, 0, len(planDiskList))
+ for _, d := range planDiskList {
+ id := types.Int64Unknown()
+ if stateId, ok := stateIdsByName[d.Name.ValueString()]; ok && !stateId.IsNull() && !stateId.IsUnknown() {
+ id = stateId
+ }
+ obj, objDiags := types.ObjectValue(serverDiskAttrTypes(), map[string]attr.Value{
+ "id": id,
+ "name": d.Name,
+ "size_gigabytes": d.SizeGigabytes,
+ })
+ resp.Diagnostics.Append(objDiags...)
+ elements = append(elements, obj)
+ }
+ newList, listDiags := types.ListValue(serverDiskObjectType(), elements)
+ resp.Diagnostics.Append(listDiags...)
+ if !listDiags.HasError() {
+ plan.Disks = newList
+ resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...)
+ }
+ }
+ }
+
if req.State.Raw.IsNull() {
// Creation plan, no further modification needed
return
@@ -500,6 +589,59 @@ func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan
)
}
+ if !plan.Disk.IsNull() && !plan.Disk.IsUnknown() &&
+ !state.Disk.IsNull() && !state.Disk.IsUnknown() &&
+ plan.Disk.ValueInt32() < state.Disk.ValueInt32() {
+ resp.Diagnostics.AddAttributeWarning(
+ path.Root("disk"),
+ "Primary disk shrink may corrupt the filesystem",
+ fmt.Sprintf(
+ "The primary disk 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. "+
+ "You must first shrink the filesystem(s) (and any partitions) on the server to fit within %d GB before applying, "+
+ "otherwise the filesystem will be irreparably corrupted and you will need to restore from backup.",
+ state.Disk.ValueInt32(), plan.Disk.ValueInt32(), plan.Disk.ValueInt32(),
+ ),
+ )
+ }
+
+ if !plan.Disks.IsNull() && !plan.Disks.IsUnknown() &&
+ !state.Disks.IsNull() && !state.Disks.IsUnknown() {
+ planDisks, pdDiags := elementsAsServerDisks(ctx, plan.Disks)
+ resp.Diagnostics.Append(pdDiags...)
+ stateDisks, sdDiags := elementsAsServerDisks(ctx, state.Disks)
+ resp.Diagnostics.Append(sdDiags...)
+ if !pdDiags.HasError() && !sdDiags.HasError() {
+ stateSizesByName := map[string]int32{}
+ for _, sd := range stateDisks {
+ if sd.Name.IsNull() || sd.Name.IsUnknown() || sd.SizeGigabytes.IsNull() || sd.SizeGigabytes.IsUnknown() {
+ continue
+ }
+ stateSizesByName[sd.Name.ValueString()] = sd.SizeGigabytes.ValueInt32()
+ }
+ for i, pd := range planDisks {
+ if pd.Name.IsNull() || pd.Name.IsUnknown() || pd.SizeGigabytes.IsNull() || pd.SizeGigabytes.IsUnknown() {
+ continue
+ }
+ prior, ok := stateSizesByName[pd.Name.ValueString()]
+ if !ok {
+ continue
+ }
+ if pd.SizeGigabytes.ValueInt32() < prior {
+ resp.Diagnostics.AddAttributeWarning(
+ path.Root("disks").AtListIndex(i).AtName("size_gigabytes"),
+ "Additional disk shrink may corrupt the filesystem",
+ fmt.Sprintf(
+ "Disk %q 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. "+
+ "You must first shrink the filesystem(s) (and any partitions) on this disk to fit within %d GB before applying, "+
+ "otherwise the filesystem will be irreparably corrupted and you will need to restore from backup.",
+ pd.Name.ValueString(), prior, pd.SizeGigabytes.ValueInt32(), pd.SizeGigabytes.ValueInt32(),
+ ),
+ )
+ }
+ }
+ }
+ }
+
if !plan.Ipv6.Equal(state.Ipv6) {
if plan.Ipv6.ValueBool() {
plan.PublicIpv6Addresses = types.ListUnknown(state.PublicIpv6Addresses.ElementType(ctx))
@@ -627,11 +769,24 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
SeparatePrivateNetworkInterface: data.SeparatePrivateNetworkInterface.ValueBoolPointer(),
}
+ // Resolve additional disks at create time so their size can be included in the initial
+ // allocation (`options.disk` is the total).
+ planDisks, diskDiags := elementsAsServerDisks(ctx, data.Disks)
+ resp.Diagnostics.Append(diskDiags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ var additionalDiskTotal int32
+ for _, d := range planDisks {
+ additionalDiskTotal += d.SizeGigabytes.ValueInt32()
+ }
+
if !data.Memory.IsNull() && !data.Memory.IsUnknown() {
body.Options.Memory = data.Memory.ValueInt32Pointer()
}
if !data.Disk.IsNull() && !data.Disk.IsUnknown() {
- body.Options.Disk = data.Disk.ValueInt32Pointer()
+ totalDisk := data.Disk.ValueInt32() + additionalDiskTotal
+ body.Options.Disk = &totalDisk
}
if !data.VpcIpv4Address.IsNull() && !data.VpcIpv4Address.IsUnknown() {
body.VpcIpv4Address = data.VpcIpv4Address.ValueStringPointer()
@@ -690,7 +845,13 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
data.Permalink = types.StringValue(*serverResp.JSON200.Server.Permalink)
data.PasswordChangeSupported = types.BoolValue(serverResp.JSON200.Server.PasswordChangeSupported)
data.Memory = types.Int32Value(serverResp.JSON200.Server.Memory)
- data.Disk = types.Int32Value(serverResp.JSON200.Server.Disk)
+ plannedPrimaryDisk := data.Disk
+ plannedDisks := data.Disks
+ {
+ var readDiags diag.Diagnostics
+ data.Disk, data.Disks = readServerDisks(ctx, serverResp.JSON200.Server.Disks, data.Disks, &readDiags)
+ resp.Diagnostics.Append(readDiags...)
+ }
plannedSourceDestCheck := data.SourceAndDestinationCheck
serverRespSourceDestCheck := types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck)
data.SourceAndDestinationCheck = serverRespSourceDestCheck
@@ -770,6 +931,18 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest,
data.SourceAndDestinationCheck = plannedSourceDestCheck
}
+ // Apply additional disks if any were planned. The initial create allocated
+ // `primary + sum(additional)` to the primary disk; createAdditionalDisks shrinks it back
+ // and adds each requested disk. Restore plannedDisks so the subsequent fetch can use the
+ // plan's names to order the result list.
+ if len(planDisks) > 0 {
+ if err := r.createAdditionalDisks(ctx, data.Id.ValueInt64(), plannedPrimaryDisk.ValueInt32(), planDisks); err != nil {
+ resp.Diagnostics.AddError("Error creating additional disks", err.Error())
+ return
+ }
+ data.Disks = plannedDisks
+ }
+
// 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)
@@ -960,28 +1133,103 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
}
}
+ // Total disk allocation reported to the API is `primary + sum(additional)`.
+ planDisks, diskDiags := elementsAsServerDisks(ctx, plan.Disks)
+ resp.Diagnostics.Append(diskDiags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ stateDisks, diskDiags := elementsAsServerDisks(ctx, state.Disks)
+ resp.Diagnostics.Append(diskDiags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ var planAdditionalTotal, stateAdditionalTotal int32
+ for _, d := range planDisks {
+ planAdditionalTotal += d.SizeGigabytes.ValueInt32()
+ }
+ for _, d := range stateDisks {
+ stateAdditionalTotal += d.SizeGigabytes.ValueInt32()
+ }
+ planDiskKnown := !plan.Disk.IsNull() && !plan.Disk.IsUnknown()
+ plannedTotalDisk := plan.Disk.ValueInt32() + planAdditionalTotal
+ stateTotalDisk := state.Disk.ValueInt32() + stateAdditionalTotal
+ totalDiskChanged := planDiskKnown && plannedTotalDisk != stateTotalDisk
+ disksChanged := planAdditionalTotal != stateAdditionalTotal || !plan.Disks.Equal(state.Disks)
+
+ // Pre-resize disk reconciliation: free space before any server-level resize so the API
+ // never sees disks > total during the operation.
+ if disksChanged {
+ planByName := make(map[string]serverDiskModel, len(planDisks))
+ for _, d := range planDisks {
+ planByName[d.Name.ValueString()] = d
+ }
+ for _, sd := range stateDisks {
+ if _, ok := planByName[sd.Name.ValueString()]; !ok {
+ if err := r.deleteServerDisk(ctx, state.Id.ValueInt64(), sd.Id.ValueInt64()); err != nil {
+ resp.Diagnostics.AddError("Error deleting additional disk", err.Error())
+ return
+ }
+ }
+ }
+ stateByName := make(map[string]serverDiskModel, len(stateDisks))
+ for _, d := range stateDisks {
+ stateByName[d.Name.ValueString()] = d
+ }
+ for _, pd := range planDisks {
+ sd, exists := stateByName[pd.Name.ValueString()]
+ if !exists {
+ continue
+ }
+ if pd.SizeGigabytes.ValueInt32() < sd.SizeGigabytes.ValueInt32() {
+ if err := r.resizeServerDisk(ctx, state.Id.ValueInt64(), sd.Id.ValueInt64(), pd.SizeGigabytes.ValueInt32()); err != nil {
+ resp.Diagnostics.AddError("Error shrinking additional disk", err.Error())
+ return
+ }
+ }
+ }
+ }
+
+ // If the primary disk is being shrunk, do it before the server-level resize so the
+ // total resize never has to span both a primary shrink and a layout change at once.
+ if planDiskKnown && plan.Disk.ValueInt32() < state.Disk.ValueInt32() {
+ currentDisks, err := r.fetchServerDisks(ctx, state.Id.ValueInt64())
+ if err != nil {
+ resp.Diagnostics.AddError("Error fetching server disks", err.Error())
+ return
+ }
+ primaryId, currentPrimarySize := findPrimaryDiskId(currentDisks)
+ if primaryId != 0 && currentPrimarySize > plan.Disk.ValueInt32() {
+ if err := r.resizeServerDisk(ctx, state.Id.ValueInt64(), primaryId, plan.Disk.ValueInt32()); err != nil {
+ resp.Diagnostics.AddError("Error shrinking primary disk", 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) ||
+ sizeChanged := !plan.Size.Equal(state.Size)
+ 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) ||
+ totalDiskChanged {
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 || totalDiskChanged {
+ 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 {
+ total := plannedTotalDisk
+ resizeReq.Options.Disk = &total
}
state.Size = plan.Size
state.Memory = plan.Memory
@@ -1053,6 +1301,66 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest,
}
}
+ // Post-resize disk reconciliation: sync primary, grow additional disks, add new ones.
+ if disksChanged || totalDiskChanged {
+ currentDisks, err := r.fetchServerDisks(ctx, state.Id.ValueInt64())
+ if err != nil {
+ resp.Diagnostics.AddError("Error fetching server disks", err.Error())
+ return
+ }
+ primaryId, primarySize := findPrimaryDiskId(currentDisks)
+ if primaryId != 0 && planDiskKnown && primarySize != plan.Disk.ValueInt32() {
+ if err := r.resizeServerDisk(ctx, state.Id.ValueInt64(), primaryId, plan.Disk.ValueInt32()); err != nil {
+ resp.Diagnostics.AddError("Error syncing primary disk size", err.Error())
+ return
+ }
+ }
+
+ existingByName := make(map[string]binarylane.Disk, len(currentDisks))
+ for _, d := range currentDisks {
+ if d.Primary {
+ continue
+ }
+ desc := ""
+ if d.Description != nil {
+ desc = *d.Description
+ }
+ existingByName[desc] = d
+ }
+ for _, pd := range planDisks {
+ existing, ok := existingByName[pd.Name.ValueString()]
+ if ok {
+ if int32(existing.SizeGigabytes) < pd.SizeGigabytes.ValueInt32() {
+ if err := r.resizeServerDisk(ctx, state.Id.ValueInt64(), existing.Id, pd.SizeGigabytes.ValueInt32()); err != nil {
+ resp.Diagnostics.AddError("Error growing additional disk", err.Error())
+ return
+ }
+ }
+ } else {
+ if err := r.addServerDisk(ctx, state.Id.ValueInt64(), pd.Name.ValueString(), pd.SizeGigabytes.ValueInt32()); err != nil {
+ resp.Diagnostics.AddError("Error adding additional disk", err.Error())
+ return
+ }
+ }
+ }
+
+ finalDisks, err := r.fetchServerDisks(ctx, state.Id.ValueInt64())
+ if err != nil {
+ resp.Diagnostics.AddError("Error refreshing server disks", err.Error())
+ return
+ }
+ newDisk, newList := readServerDisks(ctx, finalDisks, plan.Disks, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state.Disk = newDisk
+ state.Disks = newList
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ }
+
// Change IPv6
if !plan.Ipv6.Equal(state.Ipv6) {
ipv6Resp, err := r.bc.client.PostServersServerIdActionsChangeIpv6WithResponse(
@@ -1507,7 +1815,7 @@ func (r *serverResource) fetchServerResourceState(ctx context.Context, state *se
state.SourceAndDestinationCheck = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck)
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)
+ state.Disk, state.Disks = readServerDisks(ctx, serverResp.JSON200.Server.Disks, state.Disks, &diags)
state.Backups = types.BoolValue(serverResp.JSON200.Server.NextBackupWindow != nil)
state.Ipv6 = types.BoolValue(len(serverResp.JSON200.Server.Networks.V6) > 0)
@@ -1706,3 +2014,341 @@ func (r *serverResource) updateAdvancedFeatures(
return nil
}
+
+func elementsAsServerDisks(ctx context.Context, list types.List) ([]serverDiskModel, diag.Diagnostics) {
+ if list.IsNull() || list.IsUnknown() {
+ return nil, nil
+ }
+ var disks []serverDiskModel
+ diags := list.ElementsAs(ctx, &disks, false)
+ return disks, diags
+}
+
+// readServerDisks splits the server's Disks slice into the primary disk size and a list of
+// additional disks. When priorDisks is non-null the returned list is ordered to match the
+// names in priorDisks (with any new disks appended in id order) so plans don't show
+// spurious reordering diffs.
+func readServerDisks(ctx context.Context, apiDisks []binarylane.Disk, priorDisks types.List, diags *diag.Diagnostics) (types.Int32, types.List) {
+ var primarySize int32
+ additional := make([]binarylane.Disk, 0, len(apiDisks))
+
+ for _, d := range apiDisks {
+ if d.Primary {
+ primarySize = int32(d.SizeGigabytes)
+ continue
+ }
+ additional = append(additional, d)
+ }
+
+ slices.SortFunc(additional, func(a, b binarylane.Disk) int {
+ switch {
+ case a.Id < b.Id:
+ return -1
+ case a.Id > b.Id:
+ return 1
+ default:
+ return 0
+ }
+ })
+
+ // Mirror the prior null/empty shape when there are no additional disks, to avoid
+ // "provider produced inconsistent result" diffs when the user did not configure `disks`.
+ if len(additional) == 0 {
+ if priorDisks.IsNull() {
+ return types.Int32Value(primarySize), types.ListNull(serverDiskObjectType())
+ }
+ return types.Int32Value(primarySize), types.ListValueMust(serverDiskObjectType(), []attr.Value{})
+ }
+
+ if !priorDisks.IsNull() && !priorDisks.IsUnknown() {
+ prior, priorDiags := elementsAsServerDisks(ctx, priorDisks)
+ diags.Append(priorDiags...)
+ if !priorDiags.HasError() && len(prior) > 0 {
+ byName := make(map[string]int, len(additional))
+ for i, d := range additional {
+ name := ""
+ if d.Description != nil {
+ name = *d.Description
+ }
+ byName[name] = i
+ }
+ ordered := make([]binarylane.Disk, 0, len(additional))
+ used := make(map[int]struct{}, len(additional))
+ for _, p := range prior {
+ if idx, ok := byName[p.Name.ValueString()]; ok {
+ ordered = append(ordered, additional[idx])
+ used[idx] = struct{}{}
+ }
+ }
+ for i, d := range additional {
+ if _, ok := used[i]; ok {
+ continue
+ }
+ ordered = append(ordered, d)
+ }
+ additional = ordered
+ }
+ }
+
+ elements := make([]attr.Value, 0, len(additional))
+ for _, d := range additional {
+ name := ""
+ if d.Description != nil {
+ name = *d.Description
+ }
+ obj, objDiags := types.ObjectValue(serverDiskAttrTypes(), map[string]attr.Value{
+ "id": types.Int64Value(d.Id),
+ "name": types.StringValue(name),
+ "size_gigabytes": types.Int32Value(int32(d.SizeGigabytes)),
+ })
+ diags.Append(objDiags...)
+ elements = append(elements, obj)
+ }
+
+ list, listDiags := types.ListValue(serverDiskObjectType(), elements)
+ diags.Append(listDiags...)
+ if listDiags.HasError() {
+ return types.Int32Value(primarySize), types.ListNull(serverDiskObjectType())
+ }
+ return types.Int32Value(primarySize), list
+}
+
+func findPrimaryDiskId(disks []binarylane.Disk) (int64, int32) {
+ for _, d := range disks {
+ if d.Primary {
+ return d.Id, int32(d.SizeGigabytes)
+ }
+ }
+ return 0, 0
+}
+
+func (r *serverResource) resizeServerDisk(ctx context.Context, serverId, diskId int64, sizeGigabytes int32) error {
+ tflog.Info(ctx, fmt.Sprintf("Resizing disk: server_id=%d, disk_id=%d, size_gigabytes=%d", serverId, diskId, sizeGigabytes))
+ resp, err := r.bc.client.PostServersServerIdActionsResizeDiskWithResponse(ctx, serverId, binarylane.ResizeDisk{
+ Type: binarylane.ResizeDiskTypeResizeDisk,
+ DiskId: diskId,
+ SizeGigabytes: sizeGigabytes,
+ })
+ if err != nil {
+ return fmt.Errorf("error resizing disk: server_id=%d, disk_id=%d, error: %w", serverId, diskId, err)
+ }
+ if resp.StatusCode() != http.StatusOK {
+ return fmt.Errorf("unexpected HTTP status code resizing disk: server_id=%d, disk_id=%d, status=%s, body: %s", serverId, diskId, resp.Status(), resp.Body)
+ }
+ if err := r.waitForServerAction(ctx, serverId, resp.JSON200.Action.Id); err != nil {
+ return fmt.Errorf("error waiting for disk resize: server_id=%d, disk_id=%d, error: %w", serverId, diskId, err)
+ }
+ return nil
+}
+
+func (r *serverResource) deleteServerDisk(ctx context.Context, serverId, diskId int64) error {
+ tflog.Info(ctx, fmt.Sprintf("Deleting disk: server_id=%d, disk_id=%d", serverId, diskId))
+ resp, 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 resp.StatusCode() != http.StatusOK {
+ return fmt.Errorf("unexpected HTTP status code deleting disk: server_id=%d, disk_id=%d, status=%s, body: %s", serverId, diskId, resp.Status(), resp.Body)
+ }
+ if err := r.waitForServerAction(ctx, serverId, resp.JSON200.Action.Id); err != nil {
+ return fmt.Errorf("error waiting for disk delete: server_id=%d, disk_id=%d, error: %w", serverId, diskId, err)
+ }
+ return nil
+}
+
+func (r *serverResource) addServerDisk(ctx context.Context, serverId int64, name string, sizeGigabytes int32) error {
+ tflog.Info(ctx, fmt.Sprintf("Adding disk: server_id=%d, name=%s, size_gigabytes=%d", serverId, name, sizeGigabytes))
+ body := binarylane.AddDisk{
+ Type: binarylane.AddDiskTypeAddDisk,
+ SizeGigabytes: sizeGigabytes,
+ }
+ if name != "" {
+ body.Description = &name
+ }
+ resp, err := r.bc.client.PostServersServerIdActionsAddDiskWithResponse(ctx, serverId, body)
+ if err != nil {
+ return fmt.Errorf("error adding disk: server_id=%d, name=%s, error: %w", serverId, name, err)
+ }
+ if resp.StatusCode() != http.StatusOK {
+ return fmt.Errorf("unexpected HTTP status code adding disk: server_id=%d, name=%s, status=%s, body: %s", serverId, name, resp.Status(), resp.Body)
+ }
+ if err := r.waitForServerAction(ctx, serverId, resp.JSON200.Action.Id); err != nil {
+ return fmt.Errorf("error waiting for disk add: server_id=%d, name=%s, error: %w", serverId, name, err)
+ }
+ return nil
+}
+
+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
+}
+
+// createAdditionalDisks shrinks the primary disk back to plannedPrimarySize (the server
+// was created with total = primary + sum(additional), so the primary currently holds all
+// the space) and then adds each requested additional disk in plan order.
+func (r *serverResource) createAdditionalDisks(ctx context.Context, serverId int64, plannedPrimarySize int32, planDisks []serverDiskModel) error {
+ current, err := r.fetchServerDisks(ctx, serverId)
+ if err != nil {
+ return err
+ }
+ primaryId, currentPrimarySize := findPrimaryDiskId(current)
+ if primaryId != 0 && currentPrimarySize != plannedPrimarySize {
+ if err := r.resizeServerDisk(ctx, serverId, primaryId, plannedPrimarySize); err != nil {
+ return err
+ }
+ }
+ for _, d := range planDisks {
+ if err := r.addServerDisk(ctx, serverId, d.Name.ValueString(), d.SizeGigabytes.ValueInt32()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type disksRequiresPrimaryDiskValidator struct{}
+
+func (v disksRequiresPrimaryDiskValidator) Description(ctx context.Context) string {
+ return "When `disks` has one or more elements, the primary `disk` size must also be set so the total allocation is unambiguous."
+}
+
+func (v disksRequiresPrimaryDiskValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (v disksRequiresPrimaryDiskValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+ if len(req.ConfigValue.Elements()) == 0 {
+ return
+ }
+
+ var disk types.Int32
+ diags := req.Config.GetAttribute(ctx, path.Root("disk"), &disk)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ if disk.IsNull() {
+ resp.Diagnostics.AddAttributeError(
+ req.Path,
+ "Missing primary disk size",
+ "When `disks` has one or more elements, the primary `disk` size must also be set so the server's total allocation (`disk + sum(disks.size_gigabytes)`) is unambiguous.",
+ )
+ }
+}
+
+type uniqueDiskNamesValidator struct{}
+
+func (v uniqueDiskNamesValidator) Description(ctx context.Context) string {
+ return "Each `disks[*].name` must be unique within the list."
+}
+
+func (v uniqueDiskNamesValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (v uniqueDiskNamesValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+
+ var disks []serverDiskModel
+ diags := req.ConfigValue.ElementsAs(ctx, &disks, false)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ seen := make(map[string]int, len(disks))
+ for i, d := range disks {
+ if d.Name.IsNull() || d.Name.IsUnknown() {
+ continue
+ }
+ name := d.Name.ValueString()
+ if first, ok := seen[name]; ok {
+ resp.Diagnostics.AddAttributeError(
+ req.Path.AtListIndex(i).AtName("name"),
+ "Duplicate disk name",
+ fmt.Sprintf("Disk name %q is already used by disks[%d]; each `disks[*].name` must be unique within the list.", name, first),
+ )
+ continue
+ }
+ seen[name] = i
+ }
+}
+
+type totalDiskFollowsRulesValidator struct {
+ Rules []MultipleOfValidator
+}
+
+func (v totalDiskFollowsRulesValidator) Description(ctx context.Context) string {
+ return "Validates that the server's total disk allocation (`disk + sum(disks.size_gigabytes)`) follows the Binary Lane multiple-of rules."
+}
+
+func (v totalDiskFollowsRulesValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (v totalDiskFollowsRulesValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+ if len(req.ConfigValue.Elements()) == 0 {
+ return
+ }
+
+ var disk types.Int32
+ diags := req.Config.GetAttribute(ctx, path.Root("disk"), &disk)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ if disk.IsNull() || disk.IsUnknown() {
+ return
+ }
+
+ var disks []serverDiskModel
+ diags = req.ConfigValue.ElementsAs(ctx, &disks, false)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var sum int32
+ for _, d := range disks {
+ if d.SizeGigabytes.IsNull() || d.SizeGigabytes.IsUnknown() {
+ return
+ }
+ sum += d.SizeGigabytes.ValueInt32()
+ }
+ total := disk.ValueInt32() + sum
+
+ for _, rule := range v.Rules {
+ if total%rule.Multiple == 0 ||
+ rule.RangeFrom != 0 && total < rule.RangeFrom ||
+ rule.RangeTo != 0 && total >= rule.RangeTo {
+ continue
+ }
+ detail := fmt.Sprintf(
+ "The server's total disk allocation is `disk + sum(disks.size_gigabytes)` = %d + %d = %d. ",
+ disk.ValueInt32(), sum, total,
+ )
+ if rule.RangeFrom != 0 {
+ detail += fmt.Sprintf("When greater than %d, the total must be a multiple of %d.", rule.RangeFrom, rule.Multiple)
+ } else {
+ detail += fmt.Sprintf("The total must be a multiple of %d.", rule.Multiple)
+ }
+ resp.Diagnostics.AddAttributeError(req.Path, "Invalid total disk allocation", detail)
+ return
+ }
+}
diff --git a/internal/provider/server_resource_test.go b/internal/provider/server_resource_test.go
index b7d1227..4b6ebe3 100644
--- a/internal/provider/server_resource_test.go
+++ b/internal/provider/server_resource_test.go
@@ -7,11 +7,15 @@ import (
"fmt"
"log"
"net/http"
+ "reflect"
"regexp"
"strings"
"terraform-provider-binarylane/internal/binarylane"
"testing"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
@@ -183,7 +187,12 @@ resource "binarylane_server" "test" {
region = "per"
image = "debian-12"
size = "std-1vcpu"
- disk = "45"
+ disk = 40
+ disks = [
+ { name = "zeta", size_gigabytes = 5 },
+ { name = "alpha", size_gigabytes = 5 },
+ { name = "beta", size_gigabytes = 5 },
+ ]
password = "` + password1 + `"
vpc_id = null
public_ipv4_count = 0
@@ -209,7 +218,17 @@ EOT
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", "disk", "40"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.#", "3"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.name", "zeta"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "5"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.name", "alpha"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "5"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.name", "beta"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.2.id"),
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"),
@@ -257,7 +276,12 @@ resource "binarylane_server" "test" {
region = "per"
image = "debian-12"
size = "std-1vcpu"
- disk = "45"
+ disk = 35
+ disks = [
+ { name = "alpha", size_gigabytes = 10 },
+ { name = "beta", size_gigabytes = 5 },
+ { name = "delta", size_gigabytes = 5 },
+ ]
password = "` + password2 + `"
vpc_id = null
public_ipv4_count = 0
@@ -278,12 +302,262 @@ 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.#", "3"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.name", "alpha"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.0.size_gigabytes", "10"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.0.id"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.name", "beta"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.1.size_gigabytes", "5"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.1.id"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.name", "delta"),
+ resource.TestCheckResourceAttr("binarylane_server.test", "disks.2.size_gigabytes", "5"),
+ resource.TestCheckResourceAttrSet("binarylane_server.test", "disks.2.id"),
),
},
},
})
}
+func TestServerResourceDisksRequirePrimaryDisk(t *testing.T) {
+ resource.UnitTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: providerConfig + `
+resource "binarylane_server" "test" {
+ name = "tf-test-disks-no-primary"
+ region = "per"
+ image = "debian-12"
+ size = "std-1vcpu"
+ public_ipv4_count = 0
+ disks = [
+ { name = "data1", size_gigabytes = 10 },
+ ]
+}
+`,
+ PlanOnly: true,
+ ExpectError: regexp.MustCompile(`Missing primary disk size`),
+ },
+ {
+ Config: providerConfig + `
+resource "binarylane_server" "test" {
+ name = "tf-test-disks-empty"
+ region = "per"
+ image = "debian-12"
+ size = "std-1vcpu"
+ public_ipv4_count = 0
+ disks = []
+}
+`,
+ PlanOnly: true,
+ ExpectNonEmptyPlan: true,
+ },
+ {
+ Config: providerConfig + `
+resource "binarylane_server" "test" {
+ name = "tf-test-disks-duplicate"
+ region = "per"
+ image = "debian-12"
+ size = "std-1vcpu"
+ public_ipv4_count = 0
+ disk = 45
+ disks = [
+ { name = "data", size_gigabytes = 5 },
+ { name = "data", size_gigabytes = 10 },
+ ]
+}
+`,
+ PlanOnly: true,
+ ExpectError: regexp.MustCompile(`Duplicate disk name`),
+ },
+ {
+ Config: providerConfig + `
+resource "binarylane_server" "test" {
+ name = "tf-test-disks-bad-total"
+ region = "per"
+ image = "debian-12"
+ size = "std-1vcpu"
+ public_ipv4_count = 0
+ disk = 45
+ disks = [
+ { name = "data1", size_gigabytes = 1 },
+ ]
+}
+`,
+ PlanOnly: true,
+ ExpectError: regexp.MustCompile(`Invalid total disk allocation`),
+ },
+ {
+ Config: providerConfig + `
+resource "binarylane_server" "test" {
+ name = "tf-test-disks-bad-total-tier"
+ region = "per"
+ image = "debian-12"
+ size = "std-1vcpu"
+ public_ipv4_count = 0
+ disk = 50
+ disks = [
+ { name = "data1", size_gigabytes = 15 },
+ ]
+}
+`,
+ PlanOnly: true,
+ ExpectError: regexp.MustCompile(`Invalid total disk allocation`),
+ },
+ },
+ })
+}
+
+func TestReadServerDisksOrdering(t *testing.T) {
+ ctx := context.Background()
+ desc := func(s string) *string { return &s }
+
+ mustPriorList := func(names ...string) types.List {
+ elements := make([]attr.Value, 0, len(names))
+ for i, n := range names {
+ obj, diags := types.ObjectValue(serverDiskAttrTypes(), map[string]attr.Value{
+ "id": types.Int64Value(int64(i + 1)),
+ "name": types.StringValue(n),
+ "size_gigabytes": types.Int32Value(10),
+ })
+ if diags.HasError() {
+ t.Fatalf("building prior element: %s", diags)
+ }
+ elements = append(elements, obj)
+ }
+ list, diags := types.ListValue(serverDiskObjectType(), elements)
+ if diags.HasError() {
+ t.Fatalf("building prior list: %s", diags)
+ }
+ return list
+ }
+
+ extractNames := func(t *testing.T, list types.List) []string {
+ t.Helper()
+ var out []serverDiskModel
+ if diags := list.ElementsAs(ctx, &out, false); diags.HasError() {
+ t.Fatalf("extracting names: %s", diags)
+ }
+ names := make([]string, 0, len(out))
+ for _, d := range out {
+ names = append(names, d.Name.ValueString())
+ }
+ return names
+ }
+
+ t.Run("no additional disks and null prior returns ListNull", func(t *testing.T) {
+ var diags diag.Diagnostics
+ primary, list := readServerDisks(ctx,
+ []binarylane.Disk{{Id: 1, Primary: true, SizeGigabytes: 30}},
+ types.ListNull(serverDiskObjectType()), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ if primary.ValueInt32() != 30 {
+ t.Fatalf("primary = %d, want 30", primary.ValueInt32())
+ }
+ if !list.IsNull() {
+ t.Fatalf("list = %v, want null", list)
+ }
+ })
+
+ t.Run("no additional disks and non-null prior returns empty list", func(t *testing.T) {
+ var diags diag.Diagnostics
+ _, list := readServerDisks(ctx,
+ []binarylane.Disk{{Id: 1, Primary: true, SizeGigabytes: 30}},
+ types.ListValueMust(serverDiskObjectType(), []attr.Value{}), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ if list.IsNull() {
+ t.Fatalf("list is null, want empty list")
+ }
+ if len(list.Elements()) != 0 {
+ t.Fatalf("len = %d, want 0", len(list.Elements()))
+ }
+ })
+
+ t.Run("null prior falls back to id-sorted order", func(t *testing.T) {
+ var diags diag.Diagnostics
+ _, list := readServerDisks(ctx,
+ []binarylane.Disk{
+ {Id: 1, Primary: true, SizeGigabytes: 30},
+ {Id: 3, Description: desc("c"), SizeGigabytes: 10},
+ {Id: 2, Description: desc("b"), SizeGigabytes: 10},
+ {Id: 4, Description: desc("a"), SizeGigabytes: 10},
+ },
+ types.ListNull(serverDiskObjectType()), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ got := extractNames(t, list)
+ want := []string{"b", "c", "a"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("order = %v, want %v (id-sorted)", got, want)
+ }
+ })
+
+ t.Run("non-null prior preserves prior order even when ids disagree", func(t *testing.T) {
+ var diags diag.Diagnostics
+ _, list := readServerDisks(ctx,
+ []binarylane.Disk{
+ {Id: 1, Primary: true, SizeGigabytes: 30},
+ {Id: 10, Description: desc("a"), SizeGigabytes: 10},
+ {Id: 11, Description: desc("b"), SizeGigabytes: 10},
+ {Id: 12, Description: desc("c"), SizeGigabytes: 10},
+ },
+ mustPriorList("b", "c", "a"), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ got := extractNames(t, list)
+ want := []string{"b", "c", "a"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("order = %v, want %v (prior-order)", got, want)
+ }
+ })
+
+ t.Run("disks new since prior state are appended in id order", func(t *testing.T) {
+ var diags diag.Diagnostics
+ _, list := readServerDisks(ctx,
+ []binarylane.Disk{
+ {Id: 1, Primary: true, SizeGigabytes: 30},
+ {Id: 10, Description: desc("a"), SizeGigabytes: 10},
+ {Id: 11, Description: desc("b"), SizeGigabytes: 10},
+ {Id: 13, Description: desc("d"), SizeGigabytes: 10},
+ {Id: 12, Description: desc("c"), SizeGigabytes: 10},
+ },
+ mustPriorList("b", "a"), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ got := extractNames(t, list)
+ want := []string{"b", "a", "c", "d"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("order = %v, want %v (prior names then new in id order)", got, want)
+ }
+ })
+
+ t.Run("disks dropped from prior are excluded without errors", func(t *testing.T) {
+ var diags diag.Diagnostics
+ _, list := readServerDisks(ctx,
+ []binarylane.Disk{
+ {Id: 1, Primary: true, SizeGigabytes: 30},
+ {Id: 10, Description: desc("a"), SizeGigabytes: 10},
+ },
+ mustPriorList("a", "deleted-elsewhere"), &diags)
+ if diags.HasError() {
+ t.Fatalf("unexpected diags: %s", diags)
+ }
+ got := extractNames(t, list)
+ want := []string{"a"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("order = %v, want %v", got, want)
+ }
+ })
+}
+
func TestServerResourceRename(t *testing.T) {
// Must assign a password to the server or Binary Lane will send emails
password := GenerateTestPassword(t)