From 176fb8408f6a4d36e6517a6219f3b0d81c781873 Mon Sep 17 00:00:00 2001 From: Marcel Jacek <72880145+marceljk@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:36:26 +0100 Subject: [PATCH] feat: region adjustment for load balancer (#721) * remove deprecated "credential" resource of loadbalancer * region adjustment load balancer - adapted load balancer example --- docs/data-sources/loadbalancer.md | 6 +- docs/resources/loadbalancer.md | 17 +- docs/resources/loadbalancer_credential.md | 40 -- .../loadbalancer_observability_credential.md | 6 +- .../stackit_loadbalancer/resource.tf | 14 +- .../resource.tf | 6 - go.mod | 2 +- go.sum | 2 + .../loadbalancer/credential/resource.go | 343 ------------------ .../loadbalancer/credential/resource_test.go | 152 -------- .../loadbalancer/loadbalancer/datasource.go | 35 +- .../loadbalancer/loadbalancer/resource.go | 104 ++++-- .../loadbalancer/resource_test.go | 20 +- .../loadbalancer/loadbalancer_acc_test.go | 16 +- .../observability-credential/resource.go | 92 ++++- .../observability-credential/resource_test.go | 17 +- stackit/provider.go | 2 - 17 files changed, 250 insertions(+), 624 deletions(-) delete mode 100644 docs/resources/loadbalancer_credential.md delete mode 100644 examples/resources/stackit_loadbalancer_credential/resource.tf delete mode 100644 stackit/internal/services/loadbalancer/credential/resource.go delete mode 100644 stackit/internal/services/loadbalancer/credential/resource_test.go diff --git a/docs/data-sources/loadbalancer.md b/docs/data-sources/loadbalancer.md index 9d81a1c8..7b5d8bb5 100644 --- a/docs/data-sources/loadbalancer.md +++ b/docs/data-sources/loadbalancer.md @@ -27,10 +27,14 @@ data "stackit_loadbalancer" "example" { - `name` (String) Load balancer name. - `project_id` (String) STACKIT project ID to which the Load Balancer is associated. +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + ### Read-Only - `external_address` (String) External Load Balancer IP address where this Load Balancer is exposed. -- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","`name`". +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","region","`name`". - `listeners` (Attributes List) List of all listeners which will accept traffic. Limited to 20. (see [below for nested schema](#nestedatt--listeners)) - `networks` (Attributes List) List of networks that listeners and targets reside in. (see [below for nested schema](#nestedatt--networks)) - `options` (Attributes) Defines any optional functionality you want to have enabled on your load balancer. (see [below for nested schema](#nestedatt--options)) diff --git a/docs/resources/loadbalancer.md b/docs/resources/loadbalancer.md index 85d8b951..9d0136d7 100644 --- a/docs/resources/loadbalancer.md +++ b/docs/resources/loadbalancer.md @@ -35,10 +35,12 @@ resource "stackit_network_interface" "nic" { network_id = stackit_network.example_network.network_id } -# Create a public IP and assign it to the network interface +# Create a public IP for the load balancer resource "stackit_public_ip" "public-ip" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - network_interface_id = stackit_network_interface.nic.network_interface_id + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + lifecycle { + ignore_changes = [network_interface_id] + } } # Create a key pair for accessing the server instance @@ -48,7 +50,7 @@ resource "stackit_key_pair" "keypair" { } # Create a server instance -resource "stackit_server" "boot-from-volume" { +resource "stackit_server" "boot-from-image" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-server" boot_volume = { @@ -64,7 +66,7 @@ resource "stackit_server" "boot-from-volume" { # Attach the network interface to the server resource "stackit_server_network_interface_attach" "nic-attachment" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - server_id = stackit_server.boot-from-volume.server_id + server_id = stackit_server.boot-from-image.server_id network_interface_id = stackit_network_interface.nic.network_interface_id } @@ -78,7 +80,7 @@ resource "stackit_loadbalancer" "example" { target_port = 80 targets = [ { - display_name = "example-target" + display_name = stackit_server.boot-from-image.name ip = stackit_network_interface.nic.ipv4 } ] @@ -127,10 +129,11 @@ resource "stackit_loadbalancer" "example" { - `external_address` (String) External Load Balancer IP address where this Load Balancer is exposed. - `options` (Attributes) Defines any optional functionality you want to have enabled on your load balancer. (see [below for nested schema](#nestedatt--options)) +- `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only -- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","`name`". +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","region","`name`". - `private_address` (String) Transient private Load Balancer IP address. It can change any time. diff --git a/docs/resources/loadbalancer_credential.md b/docs/resources/loadbalancer_credential.md deleted file mode 100644 index e03dc6ac..00000000 --- a/docs/resources/loadbalancer_credential.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "stackit_loadbalancer_credential Resource - stackit" -subcategory: "" -description: |- - Load balancer credential resource schema. Must have a region specified in the provider configuration. - !> The stackit_loadbalancer_credential resource has been deprecated and will be removed after November 13th 2024. Please use stackit_loadbalancer_observability_credential instead, which offers the exact same functionality. ---- - -# stackit_loadbalancer_credential (Resource) - -Load balancer credential resource schema. Must have a `region` specified in the provider configuration. - -!> The `stackit_loadbalancer_credential` resource has been deprecated and will be removed after November 13th 2024. Please use `stackit_loadbalancer_observability_credential` instead, which offers the exact same functionality. - -## Example Usage - -```terraform -resource "stackit_loadbalancer_credential" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - display_name = "example-credentials" - username = "example-user" - password = "example-password" -} -``` - - -## Schema - -### Required - -- `display_name` (String) Credential name. -- `password` (String) The password used for the ARGUS instance. -- `project_id` (String) STACKIT project ID to which the load balancer credential is associated. -- `username` (String) The username used for the ARGUS instance. - -### Read-Only - -- `credentials_ref` (String) The credentials reference can be used for observability of the Load Balancer. -- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","`credentials_ref`". diff --git a/docs/resources/loadbalancer_observability_credential.md b/docs/resources/loadbalancer_observability_credential.md index 24de4d01..a4171aa2 100644 --- a/docs/resources/loadbalancer_observability_credential.md +++ b/docs/resources/loadbalancer_observability_credential.md @@ -31,7 +31,11 @@ resource "stackit_loadbalancer_observability_credential" "example" { - `project_id` (String) STACKIT project ID to which the load balancer observability credential is associated. - `username` (String) The password for the observability service (e.g. Argus) where the logs/metrics will be pushed into. +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + ### Read-Only - `credentials_ref` (String) The credentials reference is used by the Load Balancer to define which credentials it will use. -- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","`credentials_ref`". +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`","region","`credentials_ref`". diff --git a/examples/resources/stackit_loadbalancer/resource.tf b/examples/resources/stackit_loadbalancer/resource.tf index 9d2acf95..e3179ad7 100644 --- a/examples/resources/stackit_loadbalancer/resource.tf +++ b/examples/resources/stackit_loadbalancer/resource.tf @@ -16,10 +16,12 @@ resource "stackit_network_interface" "nic" { network_id = stackit_network.example_network.network_id } -# Create a public IP and assign it to the network interface +# Create a public IP for the load balancer resource "stackit_public_ip" "public-ip" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - network_interface_id = stackit_network_interface.nic.network_interface_id + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + lifecycle { + ignore_changes = [network_interface_id] + } } # Create a key pair for accessing the server instance @@ -29,7 +31,7 @@ resource "stackit_key_pair" "keypair" { } # Create a server instance -resource "stackit_server" "boot-from-volume" { +resource "stackit_server" "boot-from-image" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-server" boot_volume = { @@ -45,7 +47,7 @@ resource "stackit_server" "boot-from-volume" { # Attach the network interface to the server resource "stackit_server_network_interface_attach" "nic-attachment" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - server_id = stackit_server.boot-from-volume.server_id + server_id = stackit_server.boot-from-image.server_id network_interface_id = stackit_network_interface.nic.network_interface_id } @@ -59,7 +61,7 @@ resource "stackit_loadbalancer" "example" { target_port = 80 targets = [ { - display_name = "example-target" + display_name = stackit_server.boot-from-image.name ip = stackit_network_interface.nic.ipv4 } ] diff --git a/examples/resources/stackit_loadbalancer_credential/resource.tf b/examples/resources/stackit_loadbalancer_credential/resource.tf deleted file mode 100644 index 0ca95b22..00000000 --- a/examples/resources/stackit_loadbalancer_credential/resource.tf +++ /dev/null @@ -1,6 +0,0 @@ -resource "stackit_loadbalancer_credential" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - display_name = "example-credentials" - username = "example-user" - password = "example-password" -} diff --git a/go.mod b/go.mod index 06e36b34..5775a9db 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.21.1 - github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.18.0 + github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.21.0 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.18.0 diff --git a/go.sum b/go.sum index fda775f4..f2e1c114 100644 --- a/go.sum +++ b/go.sum @@ -163,6 +163,8 @@ github.com/stackitcloud/stackit-sdk-go/services/iaas v0.21.1 h1:ZFFJr54FcYTUBjE+ github.com/stackitcloud/stackit-sdk-go/services/iaas v0.21.1/go.mod h1:9p5IIdOKOM5/DIjbenKrWvz6GlSps4jsPJZkH7QJuRU= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.18.0 h1:7nNjIIcBQRDVnW4NL7+R8DaCKEqxxxsmVsgOVe50ZMU= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.18.0/go.mod h1:UFujBT+JlNvl6JNrY96UpLGqp+lZTD+JWL7siqhGPlw= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.0 h1:z2p0OobEAgSE5bQr+KR+4Y1QTvDbSC2a7w2eCV/oSp4= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.0/go.mod h1:x0jgrL+/K2cE4BvcIQByFUf0nOPVZRqq5Z074kjjr64= github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0 h1:P7bxaVzkZPGMWItLynKIvc88Xh6jCnK4dPTTT+L607o= github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0/go.mod h1:os4Kp2+jkMUJ2dZtgU9A91N3EJSw3MMh2slxgK1609g= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.21.0 h1:ks1i+cfD/YPRss//4aq6uvxbLvUwb5QvcUrOPeboLFY= diff --git a/stackit/internal/services/loadbalancer/credential/resource.go b/stackit/internal/services/loadbalancer/credential/resource.go deleted file mode 100644 index df67a195..00000000 --- a/stackit/internal/services/loadbalancer/credential/resource.go +++ /dev/null @@ -1,343 +0,0 @@ -package loadbalancer - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework/path" - "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-log/tflog" - "github.com/stackitcloud/stackit-sdk-go/core/config" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" - "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 = &credentialResource{} - _ resource.ResourceWithConfigure = &credentialResource{} - _ resource.ResourceWithImportState = &credentialResource{} -) - -type Model struct { - Id types.String `tfsdk:"id"` // needed by TF - ProjectId types.String `tfsdk:"project_id"` - DisplayName types.String `tfsdk:"display_name"` - Username types.String `tfsdk:"username"` - Password types.String `tfsdk:"password"` - CredentialsRef types.String `tfsdk:"credentials_ref"` -} - -// NewCredentialResource is a helper function to simplify the provider implementation. -func NewCredentialResource() resource.Resource { - return &credentialResource{} -} - -// credentialResource is the resource implementation. -type credentialResource struct { - client *loadbalancer.APIClient -} - -// Metadata returns the resource type name. -func (r *credentialResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_loadbalancer_credential" -} - -// Configure adds the provider configured client to the resource. -func (r *credentialResource) 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.WithRegion(providerData.GetRegion()), - ) - } - - 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 *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - descriptions := map[string]string{ - "main": "Load balancer credential resource schema. Must have a `region` specified in the provider configuration.", - "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"`credentials_ref`\".", - "credentials_ref": "The credentials reference can be used for observability of the Load Balancer.", - "project_id": "STACKIT project ID to which the load balancer credential is associated.", - "display_name": "Credential name.", - "username": "The username used for the ARGUS instance.", - "password": "The password used for the ARGUS instance.", - "deprecation_message": "The `stackit_loadbalancer_credential` resource has been deprecated and will be removed after November 13th 2024. " + - "Please use `stackit_loadbalancer_observability_credential` instead, which offers the exact same functionality.", - } - - resp.Schema = schema.Schema{ - Description: fmt.Sprintf("%s\n%s", descriptions["main"], descriptions["deprecation_message"]), - // Callout block: https://developer.hashicorp.com/terraform/registry/providers/docs#callouts - MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["main"], descriptions["deprecation_message"]), - DeprecationMessage: descriptions["deprecation_message"], - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Description: descriptions["id"], - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "credentials_ref": schema.StringAttribute{ - Description: descriptions["credentials_ref"], - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "project_id": schema.StringAttribute{ - Description: descriptions["project_id"], - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - validate.UUID(), - }, - }, - "display_name": schema.StringAttribute{ - Description: descriptions["display_name"], - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "username": schema.StringAttribute{ - Description: descriptions["username"], - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "password": schema.StringAttribute{ - Description: descriptions["password"], - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - }, - } -} - -// Create creates the resource and sets the initial Terraform state. -func (r *credentialResource) 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) - - // Generate API request body from model - payload, err := toCreatePayload(&model) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Creating API payload: %v", err)) - return - } - - // Create new credentials - createResp, err := r.client.CreateCredentials(ctx, projectId).CreateCredentialsPayload(*payload).XRequestID(uuid.NewString()).Execute() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Calling API: %v", err)) - return - } - ctx = tflog.SetField(ctx, "credentials_ref", createResp.Credential.CredentialsRef) - - // Map response body to schema - err = mapFields(createResp.Credential, &model) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Processing API payload: %v", err)) - return - } - - // Set state to fully populated data - diags = resp.State.Set(ctx, model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Load balancer credential created") -} - -// Read refreshes the Terraform state with the latest data. -func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.State.Get(ctx, &model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - projectId := model.ProjectId.ValueString() - credentialsRef := model.CredentialsRef.ValueString() - ctx = tflog.SetField(ctx, "project_id", projectId) - ctx = tflog.SetField(ctx, "credentials_ref", credentialsRef) - - // Get credentials - credResp, err := r.client.GetCredentials(ctx, projectId, credentialsRef).Execute() - if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped - if ok && oapiErr.StatusCode == http.StatusNotFound { - resp.State.RemoveResource(ctx) - return - } - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Calling API: %v", err)) - return - } - - // Map response body to schema - err = mapFields(credResp.Credential, &model) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Processing API payload: %v", err)) - return - } - - // Set refreshed state - diags = resp.State.Set(ctx, model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - tflog.Info(ctx, "Load balancer credential read") -} - -func (r *credentialResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - // Update shouldn't be called - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating credential", "Credential can't be updated") -} - -// Delete deletes the resource and removes the Terraform state on success. -func (r *credentialResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.State.Get(ctx, &model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - projectId := model.ProjectId.ValueString() - credentialsRef := model.CredentialsRef.ValueString() - ctx = tflog.SetField(ctx, "project_id", projectId) - ctx = tflog.SetField(ctx, "credentials_ref", credentialsRef) - - // Delete credentials - _, err := r.client.DeleteCredentials(ctx, projectId, credentialsRef).Execute() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting load balancer", fmt.Sprintf("Calling API: %v", err)) - return - } - - tflog.Info(ctx, "Load balancer credential deleted") -} - -// ImportState imports a resource into the Terraform state on success. -// The expected format of the resource import identifier is: project_id,name -func (r *credentialResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - idParts := strings.Split(req.ID, core.Separator) - - if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { - core.LogAndAddError(ctx, &resp.Diagnostics, - "Error importing credential", - fmt.Sprintf("Expected import identifier with format: [project_id],[credentials_ref] Got: %q", req.ID), - ) - return - } - - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credentials_ref"), idParts[1])...) - tflog.Info(ctx, "Load balancer credential state imported") -} - -func toCreatePayload(model *Model) (*loadbalancer.CreateCredentialsPayload, error) { - if model == nil { - return nil, fmt.Errorf("nil model") - } - - return &loadbalancer.CreateCredentialsPayload{ - DisplayName: conversion.StringValueToPointer(model.DisplayName), - Username: conversion.StringValueToPointer(model.Username), - Password: conversion.StringValueToPointer(model.Password), - }, nil -} - -func mapFields(cred *loadbalancer.CredentialsResponse, m *Model) error { - if cred == nil { - return fmt.Errorf("response input is nil") - } - if m == nil { - return fmt.Errorf("model input is nil") - } - - var credentialsRef string - if m.CredentialsRef.ValueString() != "" { - credentialsRef = m.CredentialsRef.ValueString() - } else if cred.CredentialsRef != nil { - credentialsRef = *cred.CredentialsRef - } else { - return fmt.Errorf("credentials ref not present") - } - m.CredentialsRef = types.StringValue(credentialsRef) - m.DisplayName = types.StringPointerValue(cred.DisplayName) - var username string - if m.Username.ValueString() != "" { - username = m.Username.ValueString() - } else if cred.Username != nil { - username = *cred.Username - } else { - return fmt.Errorf("username not present") - } - m.Username = types.StringValue(username) - - idParts := []string{ - m.ProjectId.ValueString(), - m.CredentialsRef.ValueString(), - } - m.Id = types.StringValue( - strings.Join(idParts, core.Separator), - ) - - return nil -} diff --git a/stackit/internal/services/loadbalancer/credential/resource_test.go b/stackit/internal/services/loadbalancer/credential/resource_test.go deleted file mode 100644 index 983a5317..00000000 --- a/stackit/internal/services/loadbalancer/credential/resource_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package loadbalancer - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "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.CreateCredentialsPayload - isValid bool - }{ - { - "default_values_ok", - &Model{}, - &loadbalancer.CreateCredentialsPayload{ - DisplayName: nil, - Username: nil, - Password: nil, - }, - true, - }, - { - "simple_values_ok", - &Model{ - DisplayName: types.StringValue("display_name"), - Username: types.StringValue("username"), - Password: types.StringValue("password"), - }, - &loadbalancer.CreateCredentialsPayload{ - DisplayName: utils.Ptr("display_name"), - Username: utils.Ptr("username"), - Password: utils.Ptr("password"), - }, - true, - }, - { - "nil_model", - nil, - nil, - false, - }, - } - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(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) - } - } - }) - } -} - -func TestMapFields(t *testing.T) { - tests := []struct { - description string - input *loadbalancer.CredentialsResponse - expected *Model - isValid bool - }{ - { - "default_values_ok", - &loadbalancer.CredentialsResponse{ - CredentialsRef: utils.Ptr("credentials_ref"), - Username: utils.Ptr("username"), - }, - &Model{ - Id: types.StringValue("pid,credentials_ref"), - ProjectId: types.StringValue("pid"), - CredentialsRef: types.StringValue("credentials_ref"), - Username: types.StringValue("username"), - }, - true, - }, - - { - "simple_values_ok", - &loadbalancer.CredentialsResponse{ - CredentialsRef: utils.Ptr("credentials_ref"), - DisplayName: utils.Ptr("display_name"), - Username: utils.Ptr("username"), - }, - &Model{ - Id: types.StringValue("pid,credentials_ref"), - ProjectId: types.StringValue("pid"), - CredentialsRef: types.StringValue("credentials_ref"), - DisplayName: types.StringValue("display_name"), - Username: types.StringValue("username"), - }, - true, - }, - { - "nil_response", - nil, - &Model{}, - false, - }, - { - "no_username", - &loadbalancer.CredentialsResponse{ - CredentialsRef: utils.Ptr("credentials_ref"), - DisplayName: utils.Ptr("display_name"), - }, - &Model{}, - false, - }, - { - "no_credentials_ref", - &loadbalancer.CredentialsResponse{ - DisplayName: utils.Ptr("display_name"), - Username: utils.Ptr("username"), - }, - &Model{}, - false, - }, - } - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - model := &Model{ - ProjectId: tt.expected.ProjectId, - } - err := mapFields(tt.input, model) - 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(model, tt.expected) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } - } - }) - } -} diff --git a/stackit/internal/services/loadbalancer/loadbalancer/datasource.go b/stackit/internal/services/loadbalancer/loadbalancer/datasource.go index 2f27475e..d8c4921c 100644 --- a/stackit/internal/services/loadbalancer/loadbalancer/datasource.go +++ b/stackit/internal/services/loadbalancer/loadbalancer/datasource.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" @@ -33,7 +34,8 @@ func NewLoadBalancerDataSource() datasource.DataSource { // loadBalancerDataSource is the data source implementation. type loadBalancerDataSource struct { - client *loadbalancer.APIClient + client *loadbalancer.APIClient + providerData core.ProviderData } // Metadata returns the data source type name. @@ -48,7 +50,8 @@ func (r *loadBalancerDataSource) Configure(ctx context.Context, req datasource.C return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.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 @@ -56,15 +59,14 @@ func (r *loadBalancerDataSource) Configure(ctx context.Context, req datasource.C var apiClient *loadbalancer.APIClient var err error - if providerData.LoadBalancerCustomEndpoint != "" { + if r.providerData.LoadBalancerCustomEndpoint != "" { apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.LoadBalancerCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.LoadBalancerCustomEndpoint), ) } else { apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.GetRegion()), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -81,7 +83,7 @@ func (r *loadBalancerDataSource) Configure(ctx context.Context, req datasource.C func (r *loadBalancerDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { descriptions := map[string]string{ "main": "Load Balancer data source schema. Must have a `region` specified in the provider configuration.", - "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"`name`\".", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"region\",\"`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.", @@ -111,6 +113,7 @@ func (r *loadBalancerDataSource) Schema(_ context.Context, _ datasource.SchemaRe "targets": "List of all targets which will be used in the pool. Limited to 1000.", "targets.display_name": "Target display name", "ip": "Target IP", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -302,6 +305,11 @@ func (r *loadBalancerDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, }, }, + "region": schema.StringAttribute{ + // the region cannot be found, so it has to be passed + Optional: true, + Description: descriptions["region"], + }, }, } } @@ -316,10 +324,17 @@ func (r *loadBalancerDataSource) Read(ctx context.Context, req datasource.ReadRe } projectId := model.ProjectId.ValueString() name := model.Name.ValueString() + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.GetRegion() + } else { + region = model.Region.ValueString() + } ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) - lbResp, err := r.client.GetLoadBalancer(ctx, projectId, name).Execute() + lbResp, err := r.client.GetLoadBalancer(ctx, projectId, region, name).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusNotFound { @@ -330,7 +345,7 @@ func (r *loadBalancerDataSource) Read(ctx context.Context, req datasource.ReadRe } // Map response body to schema - err = mapFields(ctx, lbResp, &model) + err = mapFields(ctx, lbResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading load balancer", fmt.Sprintf("Processing API payload: %v", err)) return diff --git a/stackit/internal/services/loadbalancer/loadbalancer/resource.go b/stackit/internal/services/loadbalancer/loadbalancer/resource.go index 6640d22c..79d36386 100644 --- a/stackit/internal/services/loadbalancer/loadbalancer/resource.go +++ b/stackit/internal/services/loadbalancer/loadbalancer/resource.go @@ -42,6 +42,7 @@ var ( _ resource.Resource = &loadBalancerResource{} _ resource.ResourceWithConfigure = &loadBalancerResource{} _ resource.ResourceWithImportState = &loadBalancerResource{} + _ resource.ResourceWithModifyPlan = &loadBalancerResource{} ) type Model struct { @@ -54,6 +55,7 @@ type Model struct { Options types.Object `tfsdk:"options"` PrivateAddress types.String `tfsdk:"private_address"` TargetPools types.List `tfsdk:"target_pools"` + Region types.String `tfsdk:"region"` } // Struct corresponding to Model.Listeners[i] @@ -173,7 +175,8 @@ func NewLoadBalancerResource() resource.Resource { // loadBalancerResource is the resource implementation. type loadBalancerResource struct { - client *loadbalancer.APIClient + client *loadbalancer.APIClient + providerData core.ProviderData } // Metadata returns the resource type name. @@ -181,6 +184,36 @@ func (r *loadBalancerResource) Metadata(_ context.Context, req resource.Metadata resp.TypeName = req.ProviderTypeName + "_loadbalancer" } +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *loadBalancerResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + // Configure adds the provider configured client to the resource. func (r *loadBalancerResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. @@ -188,7 +221,8 @@ func (r *loadBalancerResource) Configure(ctx context.Context, req resource.Confi return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.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 @@ -196,16 +230,15 @@ func (r *loadBalancerResource) Configure(ctx context.Context, req resource.Confi var apiClient *loadbalancer.APIClient var err error - if providerData.LoadBalancerCustomEndpoint != "" { - ctx = tflog.SetField(ctx, "loadbalancer_custom_endpoint", providerData.LoadBalancerCustomEndpoint) + if r.providerData.LoadBalancerCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "loadbalancer_custom_endpoint", r.providerData.LoadBalancerCustomEndpoint) apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.LoadBalancerCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.LoadBalancerCustomEndpoint), ) } else { apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.GetRegion()), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -225,7 +258,7 @@ func (r *loadBalancerResource) Schema(_ context.Context, _ resource.SchemaReques descriptions := map[string]string{ "main": "Load Balancer resource schema.", - "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"`name`\".", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"region\",\"`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.", @@ -255,6 +288,7 @@ func (r *loadBalancerResource) Schema(_ context.Context, _ resource.SchemaReques "targets": "List of all targets which will be used in the pool. Limited to 1000.", "targets.display_name": "Target display name", "ip": "Target IP", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -527,6 +561,15 @@ The example below creates the supporting infrastructure using the STACKIT Terraf }, }, }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -541,7 +584,9 @@ func (r *loadBalancerResource) Create(ctx context.Context, req resource.CreateRe return } projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) // Generate API request body from model payload, err := toCreatePayload(ctx, &model) @@ -551,20 +596,20 @@ func (r *loadBalancerResource) Create(ctx context.Context, req resource.CreateRe } // Create a new load balancer - createResp, err := r.client.CreateLoadBalancer(ctx, projectId).CreateLoadBalancerPayload(*payload).XRequestID(uuid.NewString()).Execute() + createResp, err := r.client.CreateLoadBalancer(ctx, projectId, region).CreateLoadBalancerPayload(*payload).XRequestID(uuid.NewString()).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating load balancer", fmt.Sprintf("Calling API: %v", err)) return } - waitResp, err := wait.CreateLoadBalancerWaitHandler(ctx, r.client, projectId, *createResp.Name).SetTimeout(90 * time.Minute).WaitWithContext(ctx) + waitResp, err := wait.CreateLoadBalancerWaitHandler(ctx, r.client, projectId, region, *createResp.Name).SetTimeout(90 * time.Minute).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating load balancer", fmt.Sprintf("Load balancer creation waiting: %v", err)) return } // Map response body to schema - err = mapFields(ctx, waitResp, &model) + err = mapFields(ctx, waitResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating load balancer", fmt.Sprintf("Processing API payload: %v", err)) return @@ -590,10 +635,16 @@ func (r *loadBalancerResource) Read(ctx context.Context, req resource.ReadReques } projectId := model.ProjectId.ValueString() name := model.Name.ValueString() + region := model.Region.ValueString() + if region == "" { + region = r.providerData.GetRegion() + } + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) - lbResp, err := r.client.GetLoadBalancer(ctx, projectId, name).Execute() + lbResp, err := r.client.GetLoadBalancer(ctx, projectId, region, name).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusNotFound { @@ -605,7 +656,7 @@ func (r *loadBalancerResource) Read(ctx context.Context, req resource.ReadReques } // Map response body to schema - err = mapFields(ctx, lbResp, &model) + err = mapFields(ctx, lbResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading load balancer", fmt.Sprintf("Processing API payload: %v", err)) return @@ -631,8 +682,10 @@ func (r *loadBalancerResource) Update(ctx context.Context, req resource.UpdateRe } projectId := model.ProjectId.ValueString() name := model.Name.ValueString() + region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) targetPoolsModel := []targetPool{} diags = model.TargetPools.ElementsAs(ctx, &targetPoolsModel, false) @@ -653,7 +706,7 @@ func (r *loadBalancerResource) Update(ctx context.Context, req resource.UpdateRe } // Update target pool - _, err = r.client.UpdateTargetPool(ctx, projectId, name, targetPoolName).UpdateTargetPoolPayload(*payload).Execute() + _, err = r.client.UpdateTargetPool(ctx, projectId, region, name, targetPoolName).UpdateTargetPoolPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating load balancer", fmt.Sprintf("Calling API for target pool: %v", err)) return @@ -662,14 +715,14 @@ func (r *loadBalancerResource) Update(ctx context.Context, req resource.UpdateRe ctx = tflog.SetField(ctx, "target_pool_name", nil) // Get updated load balancer - getResp, err := r.client.GetLoadBalancer(ctx, projectId, name).Execute() + getResp, err := r.client.GetLoadBalancer(ctx, projectId, region, name).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating load balancer", fmt.Sprintf("Calling API after update: %v", err)) return } // Map response body to schema - err = mapFields(ctx, getResp, &model) + err = mapFields(ctx, getResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating load balancer", fmt.Sprintf("Processing API payload: %v", err)) return @@ -695,17 +748,19 @@ func (r *loadBalancerResource) Delete(ctx context.Context, req resource.DeleteRe } projectId := model.ProjectId.ValueString() name := model.Name.ValueString() + region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "region", region) // Delete load balancer - _, err := r.client.DeleteLoadBalancer(ctx, projectId, name).Execute() + _, err := r.client.DeleteLoadBalancer(ctx, projectId, region, name).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting load balancer", fmt.Sprintf("Calling API: %v", err)) return } - _, err = wait.DeleteLoadBalancerWaitHandler(ctx, r.client, projectId, name).WaitWithContext(ctx) + _, err = wait.DeleteLoadBalancerWaitHandler(ctx, r.client, projectId, region, name).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting load balancer", fmt.Sprintf("Load balancer deleting waiting: %v", err)) return @@ -719,16 +774,17 @@ func (r *loadBalancerResource) Delete(ctx context.Context, req resource.DeleteRe func (r *loadBalancerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, core.Separator) - if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing load balancer", - fmt.Sprintf("Expected import identifier with format: [project_id],[name] Got: %q", req.ID), + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[name] Got: %q", req.ID), ) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[2])...) tflog.Info(ctx, "Load balancer state imported") } @@ -1012,7 +1068,7 @@ func toTargetsPayload(ctx context.Context, tp *targetPool) (*[]loadbalancer.Targ return &payload, nil } -func mapFields(ctx context.Context, lb *loadbalancer.LoadBalancer, m *Model) error { +func mapFields(ctx context.Context, lb *loadbalancer.LoadBalancer, m *Model, region string) error { if lb == nil { return fmt.Errorf("response input is nil") } @@ -1028,9 +1084,11 @@ func mapFields(ctx context.Context, lb *loadbalancer.LoadBalancer, m *Model) err } else { return fmt.Errorf("name not present") } + m.Region = types.StringValue(region) m.Name = types.StringValue(name) idParts := []string{ m.ProjectId.ValueString(), + m.Region.ValueString(), name, } m.Id = types.StringValue( diff --git a/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go b/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go index 500429cc..95a85018 100644 --- a/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go +++ b/stackit/internal/services/loadbalancer/loadbalancer/resource_test.go @@ -2,6 +2,7 @@ package loadbalancer import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -267,10 +268,13 @@ func TestToTargetPoolUpdatePayload(t *testing.T) { } func TestMapFields(t *testing.T) { + const testRegion = "eu01" + id := fmt.Sprintf("%s,%s,%s", "pid", testRegion, "name") tests := []struct { description string input *loadbalancer.LoadBalancer modelPrivateNetworkOnly *bool + region string expected *Model isValid bool }{ @@ -290,8 +294,9 @@ func TestMapFields(t *testing.T) { TargetPools: nil, }, nil, + testRegion, &Model{ - Id: types.StringValue("pid,name"), + Id: types.StringValue(id), ProjectId: types.StringValue("pid"), ExternalAddress: types.StringNull(), Listeners: types.ListNull(types.ObjectType{AttrTypes: listenerTypes}), @@ -303,6 +308,7 @@ func TestMapFields(t *testing.T) { }), PrivateAddress: types.StringNull(), TargetPools: types.ListNull(types.ObjectType{AttrTypes: targetPoolTypes}), + Region: types.StringValue(testRegion), }, true, }, @@ -361,8 +367,9 @@ func TestMapFields(t *testing.T) { }), }, nil, + testRegion, &Model{ - Id: types.StringValue("pid,name"), + Id: types.StringValue(id), ProjectId: types.StringValue("pid"), ExternalAddress: types.StringValue("external_address"), Listeners: types.ListValueMust(types.ObjectType{AttrTypes: listenerTypes}, []attr.Value{ @@ -422,6 +429,7 @@ func TestMapFields(t *testing.T) { }), }), }), + Region: types.StringValue(testRegion), }, true, }, @@ -483,8 +491,9 @@ func TestMapFields(t *testing.T) { }), }, utils.Ptr(false), + testRegion, &Model{ - Id: types.StringValue("pid,name"), + Id: types.StringValue(id), ProjectId: types.StringValue("pid"), ExternalAddress: types.StringValue("external_address"), Listeners: types.ListValueMust(types.ObjectType{AttrTypes: listenerTypes}, []attr.Value{ @@ -546,6 +555,7 @@ func TestMapFields(t *testing.T) { }), }), }), + Region: types.StringValue(testRegion), }, true, }, @@ -553,6 +563,7 @@ func TestMapFields(t *testing.T) { "nil_response", nil, nil, + testRegion, &Model{}, false, }, @@ -560,6 +571,7 @@ func TestMapFields(t *testing.T) { "no_name", &loadbalancer.LoadBalancer{}, nil, + testRegion, &Model{}, false, }, @@ -575,7 +587,7 @@ func TestMapFields(t *testing.T) { "acl": types.SetNull(types.StringType), }) } - err := mapFields(context.Background(), tt.input, model) + err := mapFields(context.Background(), tt.input, model, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/loadbalancer/loadbalancer_acc_test.go b/stackit/internal/services/loadbalancer/loadbalancer_acc_test.go index 6b48a3a2..6f79ec40 100644 --- a/stackit/internal/services/loadbalancer/loadbalancer_acc_test.go +++ b/stackit/internal/services/loadbalancer/loadbalancer_acc_test.go @@ -306,7 +306,7 @@ func TestAccLoadBalancerResource(t *testing.T) { return "", fmt.Errorf("couldn't find attribute name") } - return fmt.Sprintf("%s,%s", testutil.ProjectId, name), nil + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, name), nil }, ImportState: true, ImportStateVerify: true, @@ -331,9 +331,7 @@ func testAccCheckLoadBalancerDestroy(s *terraform.State) error { var client *loadbalancer.APIClient var err error if testutil.LoadBalancerCustomEndpoint == "" { - client, err = loadbalancer.NewAPIClient( - config.WithRegion("eu01"), - ) + client, err = loadbalancer.NewAPIClient() } else { client, err = loadbalancer.NewAPIClient( config.WithEndpoint(testutil.LoadBalancerCustomEndpoint), @@ -343,6 +341,10 @@ func testAccCheckLoadBalancerDestroy(s *terraform.State) error { return fmt.Errorf("creating client: %w", err) } + region := "eu01" + if testutil.Region != "" { + region = testutil.Region + } loadbalancersToDestroy := []string{} for _, rs := range s.RootModule().Resources { if rs.Type != "stackit_loadbalancer" { @@ -353,7 +355,7 @@ func testAccCheckLoadBalancerDestroy(s *terraform.State) error { loadbalancersToDestroy = append(loadbalancersToDestroy, loadbalancerName) } - loadbalancersResp, err := client.ListLoadBalancers(ctx, testutil.ProjectId).Execute() + loadbalancersResp, err := client.ListLoadBalancers(ctx, testutil.ProjectId, region).Execute() if err != nil { return fmt.Errorf("getting loadbalancersResp: %w", err) } @@ -369,11 +371,11 @@ func testAccCheckLoadBalancerDestroy(s *terraform.State) error { continue } if utils.Contains(loadbalancersToDestroy, *items[i].Name) { - _, err := client.DeleteLoadBalancerExecute(ctx, testutil.ProjectId, *items[i].Name) + _, err := client.DeleteLoadBalancerExecute(ctx, testutil.ProjectId, region, *items[i].Name) if err != nil { return fmt.Errorf("destroying load balancer %s during CheckDestroy: %w", *items[i].Name, err) } - _, err = wait.DeleteLoadBalancerWaitHandler(ctx, client, testutil.ProjectId, *items[i].Name).WaitWithContext(ctx) + _, err = wait.DeleteLoadBalancerWaitHandler(ctx, client, testutil.ProjectId, region, *items[i].Name).WaitWithContext(ctx) if err != nil { return fmt.Errorf("destroying load balancer %s during CheckDestroy: waiting for deletion %w", *items[i].Name, err) } diff --git a/stackit/internal/services/loadbalancer/observability-credential/resource.go b/stackit/internal/services/loadbalancer/observability-credential/resource.go index eec88bd1..130c6289 100644 --- a/stackit/internal/services/loadbalancer/observability-credential/resource.go +++ b/stackit/internal/services/loadbalancer/observability-credential/resource.go @@ -20,6 +20,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -28,6 +29,7 @@ var ( _ resource.Resource = &observabilityCredentialResource{} _ resource.ResourceWithConfigure = &observabilityCredentialResource{} _ resource.ResourceWithImportState = &observabilityCredentialResource{} + _ resource.ResourceWithModifyPlan = &observabilityCredentialResource{} ) type Model struct { @@ -37,6 +39,7 @@ type Model struct { Username types.String `tfsdk:"username"` Password types.String `tfsdk:"password"` CredentialsRef types.String `tfsdk:"credentials_ref"` + Region types.String `tfsdk:"region"` } // NewObservabilityCredentialResource is a helper function to simplify the provider implementation. @@ -46,7 +49,8 @@ func NewObservabilityCredentialResource() resource.Resource { // observabilityCredentialResource is the resource implementation. type observabilityCredentialResource struct { - client *loadbalancer.APIClient + client *loadbalancer.APIClient + providerData core.ProviderData } // Metadata returns the resource type name. @@ -54,6 +58,36 @@ func (r *observabilityCredentialResource) Metadata(_ context.Context, req resour resp.TypeName = req.ProviderTypeName + "_loadbalancer_observability_credential" } +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *observabilityCredentialResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + // Configure adds the provider configured client to the resource. func (r *observabilityCredentialResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. @@ -61,7 +95,8 @@ func (r *observabilityCredentialResource) Configure(ctx context.Context, req res return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.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 @@ -69,16 +104,15 @@ func (r *observabilityCredentialResource) Configure(ctx context.Context, req res var apiClient *loadbalancer.APIClient var err error - if providerData.LoadBalancerCustomEndpoint != "" { - ctx = tflog.SetField(ctx, "loadbalancer_custom_endpoint", providerData.LoadBalancerCustomEndpoint) + if r.providerData.LoadBalancerCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "loadbalancer_custom_endpoint", r.providerData.LoadBalancerCustomEndpoint) apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.LoadBalancerCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.LoadBalancerCustomEndpoint), ) } else { apiClient, err = loadbalancer.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.GetRegion()), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -95,12 +129,13 @@ func (r *observabilityCredentialResource) Configure(ctx context.Context, req res func (r *observabilityCredentialResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ "main": "Load balancer observability credential resource schema. Must have a `region` specified in the provider configuration. These contain the username and password for the observability service (e.g. Argus) where the load balancer logs/metrics will be pushed into", - "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"`credentials_ref`\".", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`\",\"region\",\"`credentials_ref`\".", "credentials_ref": "The credentials reference is used by the Load Balancer to define which credentials it will use.", "project_id": "STACKIT project ID to which the load balancer observability credential is associated.", "display_name": "Observability credential name.", "username": "The password for the observability service (e.g. Argus) where the logs/metrics will be pushed into.", "password": "The username for the observability service (e.g. Argus) where the logs/metrics will be pushed into.", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -151,6 +186,15 @@ func (r *observabilityCredentialResource) Schema(_ context.Context, _ resource.S stringplanmodifier.RequiresReplace(), }, }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -165,7 +209,9 @@ func (r *observabilityCredentialResource) Create(ctx context.Context, req resour return } projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) // Generate API request body from model payload, err := toCreatePayload(&model) @@ -175,7 +221,7 @@ func (r *observabilityCredentialResource) Create(ctx context.Context, req resour } // Create new observability credentials - createResp, err := r.client.CreateCredentials(ctx, projectId).CreateCredentialsPayload(*payload).XRequestID(uuid.NewString()).Execute() + createResp, err := r.client.CreateCredentials(ctx, projectId, region).CreateCredentialsPayload(*payload).XRequestID(uuid.NewString()).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating observability credential", fmt.Sprintf("Calling API: %v", err)) return @@ -183,7 +229,7 @@ func (r *observabilityCredentialResource) Create(ctx context.Context, req resour ctx = tflog.SetField(ctx, "credentials_ref", createResp.Credential.CredentialsRef) // Map response body to schema - err = mapFields(createResp.Credential, &model) + err = mapFields(createResp.Credential, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating observability credential", fmt.Sprintf("Processing API payload: %v", err)) return @@ -209,11 +255,16 @@ func (r *observabilityCredentialResource) Read(ctx context.Context, req resource } projectId := model.ProjectId.ValueString() credentialsRef := model.CredentialsRef.ValueString() + region := model.Region.ValueString() + if region == "" { + region = r.providerData.GetRegion() + } ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_ref", credentialsRef) + ctx = tflog.SetField(ctx, "region", region) // Get credentials - credResp, err := r.client.GetCredentials(ctx, projectId, credentialsRef).Execute() + credResp, err := r.client.GetCredentials(ctx, projectId, region, credentialsRef).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusNotFound { @@ -225,7 +276,7 @@ func (r *observabilityCredentialResource) Read(ctx context.Context, req resource } // Map response body to schema - err = mapFields(credResp.Credential, &model) + err = mapFields(credResp.Credential, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading observability credential", fmt.Sprintf("Processing API payload: %v", err)) return @@ -255,11 +306,13 @@ func (r *observabilityCredentialResource) Delete(ctx context.Context, req resour } projectId := model.ProjectId.ValueString() credentialsRef := model.CredentialsRef.ValueString() + region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_ref", credentialsRef) + ctx = tflog.SetField(ctx, "region", region) // Delete credentials - _, err := r.client.DeleteCredentials(ctx, projectId, credentialsRef).Execute() + _, err := r.client.DeleteCredentials(ctx, projectId, region, credentialsRef).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting observability credential", fmt.Sprintf("Calling API: %v", err)) return @@ -273,16 +326,17 @@ func (r *observabilityCredentialResource) Delete(ctx context.Context, req resour func (r *observabilityCredentialResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, core.Separator) - if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing observability credential", - fmt.Sprintf("Expected import identifier with format: [project_id],[credentials_ref] Got: %q", req.ID), + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[credentials_ref] Got: %q", req.ID), ) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credentials_ref"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credentials_ref"), idParts[2])...) tflog.Info(ctx, "Load balancer observability credential state imported") } @@ -298,7 +352,7 @@ func toCreatePayload(model *Model) (*loadbalancer.CreateCredentialsPayload, erro }, nil } -func mapFields(cred *loadbalancer.CredentialsResponse, m *Model) error { +func mapFields(cred *loadbalancer.CredentialsResponse, m *Model, region string) error { if cred == nil { return fmt.Errorf("response input is nil") } @@ -325,9 +379,11 @@ func mapFields(cred *loadbalancer.CredentialsResponse, m *Model) error { return fmt.Errorf("username not present") } m.Username = types.StringValue(username) + m.Region = types.StringValue(region) idParts := []string{ m.ProjectId.ValueString(), + m.Region.ValueString(), m.CredentialsRef.ValueString(), } m.Id = types.StringValue( diff --git a/stackit/internal/services/loadbalancer/observability-credential/resource_test.go b/stackit/internal/services/loadbalancer/observability-credential/resource_test.go index 983a5317..a6aaffe7 100644 --- a/stackit/internal/services/loadbalancer/observability-credential/resource_test.go +++ b/stackit/internal/services/loadbalancer/observability-credential/resource_test.go @@ -1,6 +1,7 @@ package loadbalancer import ( + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -67,9 +68,12 @@ func TestToCreatePayload(t *testing.T) { } func TestMapFields(t *testing.T) { + const testRegion = "eu01" + id := fmt.Sprintf("%s,%s,%s", "pid", testRegion, "credentials_ref") tests := []struct { description string input *loadbalancer.CredentialsResponse + region string expected *Model isValid bool }{ @@ -79,11 +83,13 @@ func TestMapFields(t *testing.T) { CredentialsRef: utils.Ptr("credentials_ref"), Username: utils.Ptr("username"), }, + testRegion, &Model{ - Id: types.StringValue("pid,credentials_ref"), + Id: types.StringValue(id), ProjectId: types.StringValue("pid"), CredentialsRef: types.StringValue("credentials_ref"), Username: types.StringValue("username"), + Region: types.StringValue(testRegion), }, true, }, @@ -95,18 +101,21 @@ func TestMapFields(t *testing.T) { DisplayName: utils.Ptr("display_name"), Username: utils.Ptr("username"), }, + testRegion, &Model{ - Id: types.StringValue("pid,credentials_ref"), + Id: types.StringValue(id), ProjectId: types.StringValue("pid"), CredentialsRef: types.StringValue("credentials_ref"), DisplayName: types.StringValue("display_name"), Username: types.StringValue("username"), + Region: types.StringValue(testRegion), }, true, }, { "nil_response", nil, + testRegion, &Model{}, false, }, @@ -116,6 +125,7 @@ func TestMapFields(t *testing.T) { CredentialsRef: utils.Ptr("credentials_ref"), DisplayName: utils.Ptr("display_name"), }, + testRegion, &Model{}, false, }, @@ -125,6 +135,7 @@ func TestMapFields(t *testing.T) { DisplayName: utils.Ptr("display_name"), Username: utils.Ptr("username"), }, + testRegion, &Model{}, false, }, @@ -134,7 +145,7 @@ func TestMapFields(t *testing.T) { model := &Model{ ProjectId: tt.expected.ProjectId, } - err := mapFields(tt.input, model) + err := mapFields(tt.input, model, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/provider.go b/stackit/provider.go index a392e096..ef83c2ef 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -36,7 +36,6 @@ import ( iaasServiceAccountAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/serviceaccountattach" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" iaasVolumeAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volumeattach" - loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential" logMeCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/credential" @@ -533,7 +532,6 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasSecurityGroup.NewSecurityGroupResource, iaasSecurityGroupRule.NewSecurityGroupRuleResource, loadBalancer.NewLoadBalancerResource, - loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, logMeInstance.NewInstanceResource, logMeCredential.NewCredentialResource,