feat: add support for additional disks

This commit is contained in:
Marais Rossouw
2026-06-04 17:01:16 +10:00
parent 85f65c9391
commit d168435ed6
6 changed files with 1004 additions and 22 deletions
+12 -1
View File
@@ -29,10 +29,11 @@ data "binarylane_server" "example" {
- `advanced_features` (Object) (see [below for nested schema](#nestedatt--advanced_features))
- `backups` (Boolean) If `true` this will enable two daily backups for the server. By default, backups are disabled.
- `disk` (Number) The total storage in GB for this server. Leave null to accept the default for the size Valid values:
- `disk` (Number) The 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)
<a id="nestedatt--disks"></a>
### 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.
+24 -1
View File
@@ -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.
<a id="nestedatt--disks"></a>
### 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.
<a id="nestedatt--timeouts"></a>
### Nested Schema for `timeouts`
@@ -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")
+20 -1
View File
@@ -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)
+662 -16
View File
@@ -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
}
}
+277 -3
View File
@@ -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)