From 8323db836d6d4a0ed935e4dfaa4203f7412f38f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Fri, 27 Oct 2023 11:40:29 +0200 Subject: [PATCH] Onboard Load Balancer (part 1: implement creation payload helpers) (#107) * Add initial resource schema and model * Configure client * Implement toCreatePayload and test * Unwire load balancer resource from the provider * Add schema fields descriptions * Review adjustments * Lint adjustments --- go.mod | 1 + go.sum | 2 + stackit/internal/core/core.go | 1 + .../loadbalancer/loadbalancer/resource.go | 578 ++++++++++++++++++ .../loadbalancer/resource_test.go | 167 +++++ stackit/provider.go | 4 + 6 files changed, 753 insertions(+) create mode 100644 stackit/internal/services/loadbalancer/loadbalancer/resource.go create mode 100644 stackit/internal/services/loadbalancer/loadbalancer/resource_test.go diff --git a/go.mod b/go.mod index 8ecde226..c5d4aac4 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/argus v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.4.0 + github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.4.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.5.0 diff --git a/go.sum b/go.sum index 5fb4f730..86010561 100644 --- a/go.sum +++ b/go.sum @@ -137,6 +137,8 @@ github.com/stackitcloud/stackit-sdk-go/services/argus v0.5.0 h1:BhmXA4W9P0/ttfX8 github.com/stackitcloud/stackit-sdk-go/services/argus v0.5.0/go.mod h1:fXyRrqMy2UegCRYXBYpVHcGLU41Ms0LCeTzxoYde7OI= github.com/stackitcloud/stackit-sdk-go/services/dns v0.4.0 h1:UKHDM/hKTkv5rsOH+a/7osGQaWq20MvKwETpico8akE= github.com/stackitcloud/stackit-sdk-go/services/dns v0.4.0/go.mod h1:Eyn/LRYMRtc4BB8uBxV21QA/xMUdUTkTG5ub0Q6pLVM= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.4.0 h1:YekLgXsp+nGjU3YY0mBDMc1lfNJnAuxoL1Ll7YWwv5k= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.4.0/go.mod h1:1NYHA58uZ6FJiTHBK3+HveSsz16PbuW0/wstq6iVT0I= github.com/stackitcloud/stackit-sdk-go/services/logme v0.5.0 h1:Y/AX0/yDcVvFZrK4uLJX26AdYsLxVxBlWt1mvgt/bG4= github.com/stackitcloud/stackit-sdk-go/services/logme v0.5.0/go.mod h1:Y0rtIUiDcfJLTSNbLX7wQ2N9nF1AC/WdAt45WZvZ1AM= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.5.0 h1:VFm22pao/FZRVQidPA3bg2mR76uIG8Mc2Um6EBN4rPk= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index af9e26d1..e57b41d6 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -19,6 +19,7 @@ type ProviderData struct { Region string ArgusCustomEndpoint string DnsCustomEndpoint string + LoadBalancerCustomEndpoint string LogMeCustomEndpoint string MariaDBCustomEndpoint string MongoDBFlexCustomEndpoint string diff --git a/stackit/internal/services/loadbalancer/loadbalancer/resource.go b/stackit/internal/services/loadbalancer/loadbalancer/resource.go new file mode 100644 index 00000000..1a5582c7 --- /dev/null +++ b/stackit/internal/services/loadbalancer/loadbalancer/resource.go @@ -0,0 +1,578 @@ +package loadbalancer + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &projectResource{} + _ resource.ResourceWithConfigure = &projectResource{} + _ resource.ResourceWithImportState = &projectResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ExternalAddress types.String `tfsdk:"external_address"` + Listeners []Listener `tfsdk:"listeners"` + Name types.String `tfsdk:"name"` + Networks []Network `tfsdk:"networks"` + Options types.Object `tfsdk:"options"` + PrivateAddress types.String `tfsdk:"private_address"` + TargetPools []TargetPool `tfsdk:"target_pools"` +} + +// Struct corresponding to each Model.Listener +type Listener struct { + DisplayName types.String `tfsdk:"display_name"` + Name types.String `tfsdk:"name"` + Port types.Int64 `tfsdk:"port"` + Protocol types.String `tfsdk:"protocol"` + TargetPool types.String `tfsdk:"target_pool"` +} + +// Struct corresponding to each Model.Network +type Network struct { + NetworkId types.String `tfsdk:"network_id"` + Role types.String `tfsdk:"role"` +} + +// Struct corresponding to Model.Options +type Options struct { + ACL types.List `tfsdk:"acl"` + PrivateNetworkOnly types.Bool `tfsdk:"private_network_only"` +} + +// Types corresponding to Options +var optionsTypes = map[string]attr.Type{ + "acl": basetypes.ListType{ElemType: basetypes.StringType{}}, + "private_network_only": basetypes.BoolType{}, +} + +// Struct corresponding to each Model.TargetPool +type TargetPool struct { + ActiveHealthCheck types.Object `tfsdk:"active_health_check"` + Name types.String `tfsdk:"name"` + TargetPort types.Int64 `tfsdk:"target_port"` + Targets []Target `tfsdk:"targets"` +} + +// Struct corresponding to each Model.TargetPool.ActiveHealthCheck +type ActiveHealthCheck struct { + HealthyThreshold types.Int64 `tfsdk:"healthy_threshold"` + Interval types.String `tfsdk:"interval"` + IntervalJitter types.String `tfsdk:"interval_jitter"` + Timeout types.String `tfsdk:"timeout"` + UnhealthyThreshold types.Int64 `tfsdk:"unhealthy_threshold"` +} + +// Types corresponding to ActiveHealthCheck +var activeHealthCheckTypes = map[string]attr.Type{ + "healthy_threshold": basetypes.Int64Type{}, + "interval": basetypes.StringType{}, + "interval_jitter": basetypes.StringType{}, + "timeout": basetypes.StringType{}, + "unhealthy_threshold": basetypes.Int64Type{}, +} + +// Struct corresponding to each Model.TargetPool.Targets +type Target struct { + DisplayName types.String `tfsdk:"display_name"` + Ip types.String `tfsdk:"ip"` +} + +// NewProjectResource is a helper function to simplify the provider implementation. +func NewProjectResource() resource.Resource { + return &projectResource{} +} + +// projectResource is the resource implementation. +type projectResource struct { + client *loadbalancer.APIClient +} + +// Metadata returns the resource type name. +func (r *projectResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_loadbalancer" +} + +// Configure adds the provider configured client to the resource. +func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + var apiClient *loadbalancer.APIClient + var err error + if providerData.LoadBalancerCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "loadbalancer_custom_endpoint", providerData.LoadBalancerCustomEndpoint) + apiClient, err = loadbalancer.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.LoadBalancerCustomEndpoint), + ) + } else { + apiClient, err = loadbalancer.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithServiceAccountEmail(providerData.ServiceAccountEmail), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "Load Balancer client configured") +} + +// Schema defines the schema for the resource. +func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Load Balancer resource schema.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"`name`\".", + "project_id": "STACKIT project ID to which the Load Balancer is associated.", + "external_address": "External Load Balancer IP address where this Load Balancer is exposed.", + "listeners": "List of all listeners which will accept traffic. Limited to 20.", + "listeners.name": "Will be used to reference a listener and will replace display name in the future.", + "port": "Port number where we listen for traffic.", + "protocol": "Protocol is the highest network protocol we understand to load balance.", + "target_pool": "Reference target pool by target pool name.", + "name": "Load balancer name.", + "networks": "List of networks that listeners and targets reside in.", + "network_id": "Openstack network ID.", + "role": "The role defines how the load balancer is using the network.", + "options": "Defines any optional functionality you want to have enabled on your load balancer.", + "acl": "Load Balancer is accessible only from an IP address in this range.", + "private_network_only": "If true, Load Balancer is accessible only via a private network IP address.", + "private_address": "Transient private Load Balancer IP address. It can change any time.", + "target_pools": "List of all target pools which will be used in the Load Balancer. Limited to 20.", + "healthy_threshold": "Healthy threshold of the health checking.", + "interval": "Interval duration of health checking in seconds.", + "interval_jitter": "Interval duration threshold of the health checking in seconds.", + "timeout": "Active health checking timeout duration in seconds.", + "unhealthy_threshold": "Unhealthy threshold of the health checking.", + "target_pools.name": "Target pool name.", + "target_port": "Identical port number where each target listens for traffic.", + "targets": "List of all targets which will be used in the pool. Limited to 250.", + "targets.display_name": "Target display name", + "ip": "Target IP", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the dns record set is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "external_address": schema.StringAttribute{ + Description: descriptions["external_address"], + Optional: true, + Computed: true, + }, + "listeners": schema.ListNestedAttribute{ + Description: descriptions["listeners"], + Optional: true, + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + Description: descriptions["listeners.display_name"], + Optional: true, + Computed: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["listeners.display_name"], + Computed: true, + }, + "port": schema.Int64Attribute{ + Description: descriptions["port"], + Optional: true, + Computed: true, + }, + "protocol": schema.StringAttribute{ + Description: descriptions["protocol"], + Optional: true, + Computed: true, + }, + "target_pool": schema.StringAttribute{ + Description: descriptions["target_pool"], + Optional: true, + Computed: true, + }, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + validate.NoSeparator(), + }, + }, + "networks": schema.ListNestedAttribute{ + Description: descriptions["networks"], + Optional: true, + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: descriptions["network_id"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "role": schema.StringAttribute{ + Description: descriptions["role"], + Optional: true, + Computed: true, + }, + }, + }, + }, + "options": schema.SingleNestedAttribute{ + Description: descriptions["options"], + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "acl": schema.SetAttribute{ + Description: descriptions["acl"], + ElementType: types.StringType, + Optional: true, + Computed: true, + Validators: []validator.Set{ + setvalidator.ValueStringsAre( + validate.CIDR(), + ), + }, + }, + "private_network_only": schema.BoolAttribute{ + Description: descriptions["private_network_only"], + Optional: true, + Computed: true, + }, + }, + }, + "private_address": schema.StringAttribute{ + Description: descriptions["private_address"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "target_pools": schema.ListNestedAttribute{ + Description: descriptions["target_pools"], + Optional: true, + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "active_health_check": schema.SingleNestedAttribute{ + Description: descriptions["active_health_check"], + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "healthy_threshold": schema.Int64Attribute{ + Description: descriptions["healthy_threshold"], + Optional: true, + Computed: true, + }, + "interval": schema.StringAttribute{ + Description: descriptions["interval"], + Optional: true, + Computed: true, + }, + "interval_jitter": schema.StringAttribute{ + Description: descriptions["interval_jitter"], + Optional: true, + Computed: true, + }, + "timeout": schema.StringAttribute{ + Description: descriptions["timeout"], + Optional: true, + Computed: true, + }, + "unhealthy_threshold": schema.Int64Attribute{ + Description: descriptions["unhealthy_threshold"], + Optional: true, + Computed: true, + }, + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["target_pools.name"], + Optional: true, + Computed: true, + }, + "target_port": schema.StringAttribute{ + Description: descriptions["target_port"], + Optional: true, + Computed: true, + }, + "targets": schema.ListNestedAttribute{ + Description: descriptions["targets"], + Optional: true, + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + Description: descriptions["targets.display_name"], + Optional: true, + Computed: true, + }, + "ip": schema.StringAttribute{ + Description: descriptions["ip"], + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Get status of load balancer functionality + statusResp, err := r.client.GetStatus(ctx, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error getting status of load balancer functionality", fmt.Sprintf("Calling API: %v", err)) + return + } + if *statusResp.Status != wait.FunctionalityStatusReady { + _, err = r.client.EnableLoadBalancing(ctx, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling load balancer functionality", fmt.Sprintf("Calling API: %v", err)) + return + } + + wr, err := wait.EnableLoadBalancingWaitHandler(ctx, r.client, projectId).SetTimeout(15 * time.Minute).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling load balancer functionality", fmt.Sprintf("Waiting for enablement: %v", err)) + return + } + _, ok := wr.(*loadbalancer.StatusResponse) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Wait result conversion, got %+v", wr)) + return + } + } + + // Generate API request body from model + _, err = toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *projectResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *projectResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *projectResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: container_id +func (r *projectResource) ImportState(_ context.Context, _ resource.ImportStateRequest, _ *resource.ImportStateResponse) { + +} + +func toCreatePayload(ctx context.Context, model *Model) (*loadbalancer.CreateLoadBalancerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + listeners := toListenersPayload(model) + networks := toNetworksPayload(model) + options, err := toOptionsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting options: %w", err) + } + targetPools, err := toTargetPoolsPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting target pools: %w", err) + } + + return &loadbalancer.CreateLoadBalancerPayload{ + ExternalAddress: model.ExternalAddress.ValueStringPointer(), + Listeners: listeners, + Name: model.Name.ValueStringPointer(), + Networks: networks, + Options: options, + TargetPools: targetPools, + }, nil +} + +func toListenersPayload(model *Model) *[]loadbalancer.Listener { + if model.Listeners == nil { + return nil + } + + listeners := []loadbalancer.Listener{} + for _, listener := range model.Listeners { + listeners = append(listeners, loadbalancer.Listener{ + DisplayName: listener.DisplayName.ValueStringPointer(), + Port: listener.Port.ValueInt64Pointer(), + Protocol: listener.Protocol.ValueStringPointer(), + TargetPool: listener.TargetPool.ValueStringPointer(), + }) + } + + return &listeners +} + +func toNetworksPayload(model *Model) *[]loadbalancer.Network { + if model.Networks == nil { + return nil + } + + networks := []loadbalancer.Network{} + for _, network := range model.Networks { + networks = append(networks, loadbalancer.Network{ + NetworkId: network.NetworkId.ValueStringPointer(), + Role: network.Role.ValueStringPointer(), + }) + } + + return &networks +} + +func toOptionsPayload(ctx context.Context, model *Model) (*loadbalancer.LoadBalancerOptions, error) { + var optionsModel = &Options{} + if !(model.Options.IsNull() || model.Options.IsUnknown()) { + diags := model.Options.As(ctx, optionsModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("%w", core.DiagsToError(diags)) + } + } + + accessControl := &loadbalancer.LoadbalancerOptionAccessControl{} + if !(optionsModel.ACL.IsNull() || optionsModel.ACL.IsUnknown()) { + var acl []string + diags := optionsModel.ACL.ElementsAs(ctx, &acl, false) + if diags.HasError() { + return nil, fmt.Errorf("converting acl: %w", core.DiagsToError(diags)) + } + accessControl.AllowedSourceRanges = &acl + } + + options := &loadbalancer.LoadBalancerOptions{ + AccessControl: accessControl, + PrivateNetworkOnly: optionsModel.PrivateNetworkOnly.ValueBoolPointer(), + } + + return options, nil +} + +func toTargetPoolsPayload(ctx context.Context, model *Model) (*[]loadbalancer.TargetPool, error) { + if model.TargetPools == nil { + return nil, nil + } + + var targetPools []loadbalancer.TargetPool + for _, targetPool := range model.TargetPools { + var activeHealthCheck *loadbalancer.ActiveHealthCheck + if !(targetPool.ActiveHealthCheck.IsNull() || targetPool.ActiveHealthCheck.IsUnknown()) { + var activeHealthCheckModel ActiveHealthCheck + diags := targetPool.ActiveHealthCheck.As(ctx, &activeHealthCheckModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting active health check: %w", core.DiagsToError(diags)) + } + + activeHealthCheck = &loadbalancer.ActiveHealthCheck{ + HealthyThreshold: activeHealthCheckModel.HealthyThreshold.ValueInt64Pointer(), + Interval: activeHealthCheckModel.Interval.ValueStringPointer(), + IntervalJitter: activeHealthCheckModel.IntervalJitter.ValueStringPointer(), + Timeout: activeHealthCheckModel.Timeout.ValueStringPointer(), + UnhealthyThreshold: activeHealthCheckModel.UnhealthyThreshold.ValueInt64Pointer(), + } + } + + var targets []loadbalancer.Target + for _, target := range targetPool.Targets { + targets = append(targets, loadbalancer.Target{ + DisplayName: target.DisplayName.ValueStringPointer(), + Ip: target.Ip.ValueStringPointer(), + }) + } + + targetPools = append(targetPools, loadbalancer.TargetPool{ + ActiveHealthCheck: activeHealthCheck, + Name: targetPool.Name.ValueStringPointer(), + TargetPort: targetPool.TargetPort.ValueInt64Pointer(), + Targets: &targets, + }) + } + + return &targetPools, nil +} diff --git a/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go b/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go new file mode 100644 index 00000000..832fff8c --- /dev/null +++ b/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go @@ -0,0 +1,167 @@ +package loadbalancer + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *loadbalancer.CreateLoadBalancerPayload + isValid bool + }{ + { + "default_values_ok", + &Model{}, + &loadbalancer.CreateLoadBalancerPayload{ + ExternalAddress: nil, + Listeners: nil, + Name: nil, + Networks: nil, + Options: &loadbalancer.LoadBalancerOptions{ + AccessControl: &loadbalancer.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: nil, + }, + PrivateNetworkOnly: nil, + }, + TargetPools: nil, + }, + true, + }, + { + "simple_values_ok", + &Model{ + ExternalAddress: types.StringValue("external_address"), + Listeners: []Listener{ + { + DisplayName: types.StringValue("display_name"), + Port: types.Int64Value(80), + Protocol: types.StringValue("protocol"), + TargetPool: types.StringValue("target_pool"), + }, + }, + Name: types.StringValue("name"), + Networks: []Network{ + { + NetworkId: types.StringValue("network_id"), + Role: types.StringValue("role"), + }, + { + NetworkId: types.StringValue("network_id_2"), + Role: types.StringValue("role_2"), + }, + }, + Options: types.ObjectValueMust( + optionsTypes, + map[string]attr.Value{ + "acl": types.ListValueMust( + types.StringType, + []attr.Value{types.StringValue("cidr")}), + "private_network_only": types.BoolValue(true), + }, + ), + TargetPools: []TargetPool{ + { + ActiveHealthCheck: types.ObjectValueMust( + activeHealthCheckTypes, + map[string]attr.Value{ + "healthy_threshold": types.Int64Value(1), + "interval": types.StringValue("2s"), + "interval_jitter": types.StringValue("3s"), + "timeout": types.StringValue("4s"), + "unhealthy_threshold": types.Int64Value(5), + }, + ), + Name: types.StringValue("name"), + TargetPort: types.Int64Value(80), + Targets: []Target{ + { + DisplayName: types.StringValue("display_name"), + Ip: types.StringValue("ip"), + }, + }, + }, + }, + }, + &loadbalancer.CreateLoadBalancerPayload{ + ExternalAddress: utils.Ptr("external_address"), + Listeners: utils.Ptr([]loadbalancer.Listener{ + { + DisplayName: utils.Ptr("display_name"), + Port: utils.Ptr(int64(80)), + Protocol: utils.Ptr("protocol"), + TargetPool: utils.Ptr("target_pool"), + }, + }), + Name: utils.Ptr("name"), + Networks: utils.Ptr([]loadbalancer.Network{ + { + NetworkId: utils.Ptr("network_id"), + Role: utils.Ptr("role"), + }, + { + NetworkId: utils.Ptr("network_id_2"), + Role: utils.Ptr("role_2"), + }, + }), + Options: utils.Ptr(loadbalancer.LoadBalancerOptions{ + AccessControl: &loadbalancer.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: utils.Ptr([]string{"cidr"}), + }, + PrivateNetworkOnly: utils.Ptr(true), + }), + TargetPools: utils.Ptr([]loadbalancer.TargetPool{ + { + ActiveHealthCheck: utils.Ptr(loadbalancer.ActiveHealthCheck{ + HealthyThreshold: utils.Ptr(int64(1)), + Interval: utils.Ptr("2s"), + IntervalJitter: utils.Ptr("3s"), + Timeout: utils.Ptr("4s"), + UnhealthyThreshold: utils.Ptr(int64(5)), + }), + Name: utils.Ptr("name"), + TargetPort: utils.Ptr(int64(80)), + Targets: utils.Ptr([]loadbalancer.Target{ + { + DisplayName: utils.Ptr("display_name"), + Ip: utils.Ptr("ip"), + }, + }), + }, + }), + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 7f3aee53..d4f15b53 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -82,6 +82,7 @@ type providerModel struct { PostgreSQLCustomEndpoint types.String `tfsdk:"postgresql_custom_endpoint"` PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` + LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"` LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` RabbitMQCustomEndpoint types.String `tfsdk:"rabbitmq_custom_endpoint"` MariaDBCustomEndpoint types.String `tfsdk:"mariadb_custom_endpoint"` @@ -276,6 +277,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, if !(providerConfig.MongoDBFlexCustomEndpoint.IsUnknown() || providerConfig.MongoDBFlexCustomEndpoint.IsNull()) { providerData.MongoDBFlexCustomEndpoint = providerConfig.MongoDBFlexCustomEndpoint.ValueString() } + if !(providerConfig.LoadBalancerCustomEndpoint.IsUnknown() || providerConfig.LoadBalancerCustomEndpoint.IsNull()) { + providerData.LoadBalancerCustomEndpoint = providerConfig.LoadBalancerCustomEndpoint.ValueString() + } if !(providerConfig.LogMeCustomEndpoint.IsUnknown() || providerConfig.LogMeCustomEndpoint.IsNull()) { providerData.LogMeCustomEndpoint = providerConfig.LogMeCustomEndpoint.ValueString() }