From 50d06b967be5ea2418b65781de86914de3470e5e Mon Sep 17 00:00:00 2001 From: Oscar Hermoso Date: Fri, 22 Nov 2024 10:12:22 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=A3=20`source=5Fand=5Fdestination=5Fch?= =?UTF-8?q?eck`=20for=20server=20resource=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐣 source_and_destination_check for server resource * Ensure default values are handled correctly --- docs/resources/server.md | 1 + examples/vpc/main.tf | 13 +- internal/provider/server_resource.go | 150 +++++++++++++++++++--- internal/provider/server_resource_test.go | 4 + internal/provider/util.go | 4 + 5 files changed, 145 insertions(+), 27 deletions(-) diff --git a/docs/resources/server.md b/docs/resources/server.md index 5661957..f686689 100644 --- a/docs/resources/server.md +++ b/docs/resources/server.md @@ -28,6 +28,7 @@ Provides a Binary Lane Server resource. This can be used to create and delete se - `name` (String) The hostname of your server, such as vps01.yourcompany.com. If not provided, the server will be created with a random name. - `password` (String, Sensitive) If this is provided the specified or default remote user's account password will be set to this value. Only valid if the server supports password change actions. If omitted and the server supports password change actions a random password will be generated and emailed to the account email address. - `port_blocking` (Boolean) Port blocking of outgoing connections for email, SSH and Remote Desktop (TCP ports 22, 25, and 3389) is enabled by default for all new servers. If this is false port blocking will be disabled. Disabling port blocking is only available to reviewed accounts. +- `source_and_destination_check` (Boolean) This attribute can only be set if your server also has a `vpc_id` attribute set. When enabled (which is `true` by default), your server will only be able to send or receive packets that are directly addressed to one of the IP addresses associated with the Cloud Server. Generally, this is desirable behaviour because it prevents IP conflicts and other hard-to-diagnose networking faults due to incorrect network configuration. When `source_and_destination_check` is `false`, your Cloud Server will be able to send and receive packets addressed to any server. This is typically used when you want to use your Cloud Server as a VPN endpoint, a NAT server to provide internet access, or IP forwarding. - `ssh_keys` (List of Number) This is a list of SSH key ids. If this is null or not provided, any SSH keys that have been marked as default will be deployed (assuming the operating system supports SSH Keys). Submit an empty list to disable deployment of default keys. - `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) - `user_data` (String) If provided this will be used to initialise the new server. This must be left null if the Image does not support UserData, see DistributionInfo.Features for more information. diff --git a/examples/vpc/main.tf b/examples/vpc/main.tf index 4cc8b77..4ec7390 100644 --- a/examples/vpc/main.tf +++ b/examples/vpc/main.tf @@ -35,12 +35,13 @@ resource "binarylane_server" "db" { # VPN server resource "binarylane_server" "vpn" { - name = "tf-example-vpc-vpn" - region = "per" - image = "ubuntu-24.04" - size = "std-min" - vpc_id = binarylane_vpc.example.id - public_ipv4_count = 1 + name = "tf-example-vpc-vpn" + region = "per" + image = "ubuntu-24.04" + size = "std-min" + vpc_id = binarylane_vpc.example.id + public_ipv4_count = 1 + source_and_destination_check = false } resource "binarylane_vpc_route_entries" "example" { diff --git a/internal/provider/server_resource.go b/internal/provider/server_resource.go index ae986c6..962342a 100644 --- a/internal/provider/server_resource.go +++ b/internal/provider/server_resource.go @@ -12,6 +12,7 @@ import ( "time" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" @@ -50,13 +51,14 @@ type serverResource struct { type serverModel struct { resources.ServerModel - PublicIpv4Count types.Int32 `tfsdk:"public_ipv4_count"` - PublicIpv4Addresses types.List `tfsdk:"public_ipv4_addresses"` - PrivateIPv4Addresses types.List `tfsdk:"private_ipv4_addresses"` - Permalink types.String `tfsdk:"permalink"` - Password types.String `tfsdk:"password"` - PasswordChangeSupported types.Bool `tfsdk:"password_change_supported"` - Timeouts timeouts.Value `tfsdk:"timeouts"` + PublicIpv4Count types.Int32 `tfsdk:"public_ipv4_count"` + PublicIpv4Addresses types.List `tfsdk:"public_ipv4_addresses"` + PrivateIPv4Addresses types.List `tfsdk:"private_ipv4_addresses"` + SourceAndDestinationCheck types.Bool `tfsdk:"source_and_destination_check"` + Permalink types.String `tfsdk:"permalink"` + Password types.String `tfsdk:"password"` + PasswordChangeSupported types.Bool `tfsdk:"password_change_supported"` + Timeouts timeouts.Value `tfsdk:"timeouts"` } func (d *serverResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { @@ -199,6 +201,26 @@ func (r *serverResource) Schema(ctx context.Context, _ resource.SchemaRequest, r Computed: true, } + sourceDestCheckDescription := "This attribute can only be set if your server also has a `vpc_id` attribute set. " + + "When enabled (which is `true` by default), your server will only be able to send or receive " + + "packets that are directly addressed to one of the IP addresses associated with the Cloud Server. Generally, " + + "this is desirable behaviour because it prevents IP conflicts and other hard-to-diagnose networking faults due " + + "to incorrect network configuration. When `source_and_destination_check` is `false`, your Cloud Server will be able " + + "to send and receive packets addressed to any server. This is typically used when you want to use " + + "your Cloud Server as a VPN endpoint, a NAT server to provide internet access, or IP forwarding." + resp.Schema.Attributes["source_and_destination_check"] = &schema.BoolAttribute{ + Description: sourceDestCheckDescription, + MarkdownDescription: sourceDestCheckDescription, + Optional: true, + Required: false, + Computed: true, + Validators: []validator.Bool{ + boolvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("vpc_id"), + }...), + }, + } + privateIpv4AddressesDescription := "The private IPv4 addresses assigned to the server." resp.Schema.Attributes["private_ipv4_addresses"] = &schema.ListAttribute{ Description: privateIpv4AddressesDescription, @@ -248,16 +270,33 @@ func (r *serverResource) Schema(ctx context.Context, _ resource.SchemaRequest, r } func (r *serverResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { - // Creation or destruction plan - if req.Plan.Raw.IsNull() || req.State.Raw.IsNull() { + var plan, state serverModel + + if req.Plan.Raw.IsNull() { + // Destruction plan, no modification needed + return + } + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { return } - var plan, state serverModel + if plan.SourceAndDestinationCheck.IsUnknown() { + if plan.VpcId.IsNull() { + plan.SourceAndDestinationCheck = types.BoolNull() + } else { + plan.SourceAndDestinationCheck = types.BoolPointerValue(Pointer(true)) + } + resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) + } - // Read Terraform plan data into the model + if req.State.Raw.IsNull() { + // Creation plan, no further modification needed + return + } + // Read Terraform state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } @@ -374,7 +413,7 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, fmt.Sprintf("Received %s creating new server: name=%s. Details: %s", serverResp.Status(), data.Name.ValueString(), serverResp.Body)) return } - err = r.waitForServerAction(ctx, *serverResp.JSON200.Server.Id, createActionId, "create") + err = r.waitForServerAction(ctx, *serverResp.JSON200.Server.Id, createActionId) if err != nil { resp.Diagnostics.AddError("Error waiting for server to be created", err.Error()) } @@ -389,6 +428,9 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, data.VpcId = types.Int64PointerValue(serverResp.JSON200.Server.VpcId) data.Permalink = types.StringValue(*serverResp.JSON200.Server.Permalink) data.PasswordChangeSupported = types.BoolValue(*serverResp.JSON200.Server.PasswordChangeSupported) + plannedSourceDestCheck := data.SourceAndDestinationCheck + serverRespSourceDestCheck := types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck) + data.SourceAndDestinationCheck = serverRespSourceDestCheck publicIpv4Addresses := []string{} privateIpv4Addresses := []string{} @@ -406,6 +448,19 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + // Update source_and_destination_check if needed + if plannedSourceDestCheck.Equal(types.BoolPointerValue(Pointer(false))) { + err := r.updateSourceDestCheck(ctx, data.Id.ValueInt64(), false) + if err != nil { + resp.Diagnostics.AddError("Error updating source and destination check", err.Error()) + return + } + data.SourceAndDestinationCheck = plannedSourceDestCheck + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { @@ -447,6 +502,7 @@ func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, res data.VpcId = types.Int64PointerValue(serverResp.JSON200.Server.VpcId) data.Permalink = types.StringValue(*serverResp.JSON200.Server.Permalink) data.PasswordChangeSupported = types.BoolValue(*serverResp.JSON200.Server.PasswordChangeSupported) + data.SourceAndDestinationCheck = types.BoolPointerValue(serverResp.JSON200.Server.Networks.SourceAndDestinationCheck) publicIpv4Addresses := []string{} privateIpv4Addresses := []string{} @@ -598,7 +654,7 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, fmt.Sprintf("Received %s resizing server: server_id=%s. Details: %s", resizeResp.Status(), state.Id.String(), resizeResp.Body)) return } - err = r.waitForServerAction(ctx, state.Id.ValueInt64(), *resizeResp.JSON200.Action.Id, "resize") + err = r.waitForServerAction(ctx, state.Id.ValueInt64(), *resizeResp.JSON200.Action.Id) if err != nil { resp.Diagnostics.AddError("Error waiting for server to be resized", err.Error()) return @@ -669,7 +725,7 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, fmt.Sprintf("Received %s rebuilding server: server_id=%s. Details: %s", rebuildResp.Status(), state.Id.String(), rebuildResp.Body)) return } - err = r.waitForServerAction(ctx, state.Id.ValueInt64(), *rebuildResp.JSON200.Action.Id, "rebuild") + err = r.waitForServerAction(ctx, state.Id.ValueInt64(), *rebuildResp.JSON200.Action.Id) if err != nil { resp.Diagnostics.AddError("Error waiting for server to be rebuilt", err.Error()) return @@ -682,6 +738,22 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } + + // Check source_and_destination_check + if !plan.SourceAndDestinationCheck.Equal(state.SourceAndDestinationCheck) { + if !plan.SourceAndDestinationCheck.IsNull() { + err := r.updateSourceDestCheck(ctx, state.Id.ValueInt64(), plan.SourceAndDestinationCheck.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating source and destination check", err.Error()) + return + } + } + + state.SourceAndDestinationCheck = plan.SourceAndDestinationCheck + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + } } func (r *serverResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { @@ -765,30 +837,35 @@ func (r *serverResource) ImportState( resp.Diagnostics.Append(diags...) } -func (r *serverResource) waitForServerAction(ctx context.Context, serverId int64, actionId int64, actionType string) error { +func (r *serverResource) waitForServerAction(ctx context.Context, serverId int64, actionId int64) error { var lastReadyResp *binarylane.GetServersServerIdActionsActionIdResponse for { select { case <-ctx.Done(): if lastReadyResp == nil { - return fmt.Errorf("timed out waiting for server %d to %s", serverId, actionType) + return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d", serverId, actionId) } else { - return fmt.Errorf("timed out waiting for server %d to %s, last response was status=%s, body: %s", serverId, actionType, lastReadyResp.Status(), lastReadyResp.Body) + return fmt.Errorf("timed out waiting for server action: server_id=%d, action_id=%d, last response was status=%s, body: %s", + serverId, actionId, lastReadyResp.Status(), lastReadyResp.Body) } default: readyResp, err := r.bc.client.GetServersServerIdActionsActionIdWithResponse(ctx, serverId, actionId) if err != nil { - return fmt.Errorf("unexpected error waiting for server %d to %s: %w", serverId, actionType, err) + return fmt.Errorf("unexpected error waiting for server action: server_id=%d, action_id=%d, error: %w", serverId, actionId, err) } if readyResp.StatusCode() == http.StatusOK && *readyResp.JSON200.Action.Status == binarylane.Errored { - return fmt.Errorf("server %d failed to %s with error: %s", serverId, actionType, *readyResp.JSON200.Action.ResultData) + return fmt.Errorf("server action failed to with error: server_id=%d, action_id=%d, error: %s", serverId, actionId, *readyResp.JSON200.Action.ResultData) } if readyResp.StatusCode() == http.StatusOK && readyResp.JSON200.Action.CompletedAt != nil { return nil } lastReadyResp = readyResp - tflog.Debug(ctx, fmt.Sprintf("waiting for server %d to %s: last response was status=%s, details: %s", serverId, actionType, readyResp.Status(), readyResp.Body)) + tflog.Debug(ctx, + fmt.Sprintf("waiting for server action for server_id=%d, action_id=%d: last response was status=%s, details: %s", + serverId, actionId, readyResp.Status(), readyResp.Body, + ), + ) } time.Sleep(time.Second * 5) } @@ -809,3 +886,34 @@ func attrsRequiringRebuild(plan *serverModel, state *serverModel) []string { return attrs } + +func (r *serverResource) updateSourceDestCheck( + ctx context.Context, + serverId int64, + sourceDestCheckEnabled bool, +) error { + tflog.Info(ctx, fmt.Sprintf("Changing source and destination check for server: server_id=%d, enabled=%t", + serverId, sourceDestCheckEnabled)) + + sourceDestCheckResp, err := r.bc.client.PostServersServerIdActionsChangeSourceAndDestinationCheckWithResponse( + ctx, + serverId, + binarylane.PostServersServerIdActionsChangeSourceAndDestinationCheckJSONRequestBody{ + Type: "change_source_and_destination_check", + Enabled: sourceDestCheckEnabled, + }, + ) + if err != nil { + return fmt.Errorf("error changing source and destination check for server: server_id=%d, error: %w", serverId, err) + } + if sourceDestCheckResp.StatusCode() != http.StatusOK { + return fmt.Errorf("unexpected HTTP status code changing source and destination check for server: server_id=%d, details: %s", serverId, sourceDestCheckResp.Body) + } + + err = r.waitForServerAction(ctx, serverId, *sourceDestCheckResp.JSON200.Action.Id) + if err != nil { + return fmt.Errorf("error changing source and destination check: %w", err) + } + + return nil +} diff --git a/internal/provider/server_resource_test.go b/internal/provider/server_resource_test.go index 28ad16e..e1df1a2 100644 --- a/internal/provider/server_resource_test.go +++ b/internal/provider/server_resource_test.go @@ -60,6 +60,7 @@ resource "binarylane_server" "test" { vpc_id = binarylane_vpc.test.id public_ipv4_count = 1 ssh_keys = [binarylane_ssh_key.initial.id] + source_and_destination_check = false user_data = < /var/tmp/output.txt @@ -91,6 +92,7 @@ echo "Hello World" > /var/tmp/output.txt resource.TestCheckResourceAttr("binarylane_server.test", "ssh_keys.#", "1"), resource.TestCheckResourceAttrPair("binarylane_server.test", "ssh_keys.0", "binarylane_ssh_key.initial", "id"), resource.TestCheckResourceAttrSet("binarylane_server.test", "permalink"), + resource.TestCheckResourceAttr("binarylane_server.test", "source_and_destination_check", "false"), // Verify data source values resource.TestCheckResourceAttrPair("data.binarylane_server.test", "id", "binarylane_server.test", "id"), @@ -148,6 +150,7 @@ resource "binarylane_server" "test" { vpc_id = binarylane_vpc.test.id public_ipv4_count = 0 ssh_keys = [binarylane_ssh_key.updated.id] + # source_and_destination_check = true # defaults to true user_data = < /var/tmp/output.txt @@ -164,6 +167,7 @@ EOT resource.TestCheckResourceAttr("binarylane_server.test", "image", "debian-12"), resource.TestCheckResourceAttr("binarylane_server.test", "ssh_keys.#", "1"), resource.TestCheckResourceAttrPair("binarylane_server.test", "ssh_keys.0", "binarylane_ssh_key.updated", "id"), + resource.TestCheckResourceAttr("binarylane_server.test", "source_and_destination_check", "true"), resource.TestCheckResourceAttr("binarylane_server.test", "user_data", // test extra whitespace `#cloud-config echo "Hello Whitespace" > /var/tmp/output.txt diff --git a/internal/provider/util.go b/internal/provider/util.go index bafb525..b8c0560 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -177,3 +177,7 @@ func listContainsUnknown(ctx context.Context, list types.List) bool { return false } + +func Pointer[T any](d T) *T { + return &d +}