Skip to content

Commit

Permalink
🐣 source_and_destination_check for server resource (#48)
Browse files Browse the repository at this point in the history
* 🐣 source_and_destination_check for server resource

* Ensure default values are handled correctly
  • Loading branch information
oscarhermoso authored Nov 22, 2024
1 parent e6c50a4 commit 50d06b9
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 27 deletions.
1 change: 1 addition & 0 deletions docs/resources/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions examples/vpc/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
150 changes: 129 additions & 21 deletions internal/provider/server_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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())
}
Expand All @@ -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{}
Expand All @@ -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) {
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
4 changes: 4 additions & 0 deletions internal/provider/server_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <<EOT
#cloud-config
echo "Hello World" > /var/tmp/output.txt
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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 = <<EOT
#cloud-config
echo "Hello Whitespace" > /var/tmp/output.txt
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/provider/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,7 @@ func listContainsUnknown(ctx context.Context, list types.List) bool {

return false
}

func Pointer[T any](d T) *T {
return &d
}

0 comments on commit 50d06b9

Please sign in to comment.