From d168435ed641eafa5d1b5bb65d863fa14c010a50 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Thu, 4 Jun 2026 17:01:16 +1000 Subject: [PATCH] feat: add support for additional disks --- docs/data-sources/server.md | 13 +- docs/resources/server.md | 25 +- .../resources/binarylane_server/resource.tf | 9 + internal/provider/server_data_source.go | 21 +- internal/provider/server_resource.go | 678 +++++++++++++++++- internal/provider/server_resource_test.go | 280 +++++++- 6 files changed, 1004 insertions(+), 22 deletions(-) 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)