forked from mirrors/terraform-provider-binarylane
feat: add support for additional disks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user