Deprecate stackit_loadbalancer_credential and add new stackit_loadbalancer_observability_credential (#357)

* Copy over old resource files

* Adjustments to the new observability credential resource.go

* Register new resource in the provider

* Add example

* Adapt acc test

* Update docs

* Add deprecation message

* Fix linter

* Add deprecation message to dscription
This commit is contained in:
João Palet 2024-05-13 15:30:51 +01:00 committed by GitHub
parent 6eb1310056
commit ee905a3a5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 580 additions and 14 deletions

View file

@ -102,10 +102,15 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest,
"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: descriptions["main"],
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"],

View file

@ -94,7 +94,7 @@ func configResources(targetPort string) string {
}
}
resource "stackit_loadbalancer_credential" "credential" {
resource "stackit_loadbalancer_observability_credential" "credential" {
project_id = "%s"
display_name = "%s"
username = "%s"
@ -245,13 +245,13 @@ func TestAccLoadBalancerResource(t *testing.T) {
// Credential
resource.TestCheckResourceAttrPair(
"stackit_loadbalancer_credential.credential", "project_id",
"stackit_loadbalancer_observability_credential.credential", "project_id",
"stackit_loadbalancer.loadbalancer", "project_id",
),
resource.TestCheckResourceAttrSet("stackit_loadbalancer_credential.credential", "credentials_ref"),
resource.TestCheckResourceAttr("stackit_loadbalancer_credential.credential", "display_name", loadBalancerResource["credential_display_name"]),
resource.TestCheckResourceAttr("stackit_loadbalancer_credential.credential", "username", loadBalancerResource["credential_username"]),
resource.TestCheckResourceAttr("stackit_loadbalancer_credential.credential", "password", loadBalancerResource["credential_password"]),
resource.TestCheckResourceAttrSet("stackit_loadbalancer_observability_credential.credential", "credentials_ref"),
resource.TestCheckResourceAttr("stackit_loadbalancer_observability_credential.credential", "display_name", loadBalancerResource["credential_display_name"]),
resource.TestCheckResourceAttr("stackit_loadbalancer_observability_credential.credential", "username", loadBalancerResource["credential_username"]),
resource.TestCheckResourceAttr("stackit_loadbalancer_observability_credential.credential", "password", loadBalancerResource["credential_password"]),
),
},
// Data source
@ -318,11 +318,11 @@ func TestAccLoadBalancerResource(t *testing.T) {
ImportStateVerify: true,
},
{
ResourceName: "stackit_loadbalancer_credential.credential",
ResourceName: "stackit_loadbalancer_observability_credential.credential",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_loadbalancer_credential.credential"]
r, ok := s.RootModule().Resources["stackit_loadbalancer_observability_credential.credential"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_loadbalancer_credential.credential")
return "", fmt.Errorf("couldn't find resource stackit_loadbalancer_observability_credential.credential")
}
credentialsRef, ok := r.Primary.Attributes["credentials_ref"]
if !ok {

View file

@ -0,0 +1,361 @@
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/stackit-sdk-go/services/loadbalancer/wait"
"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 = &observabilityCredentialResource{}
_ resource.ResourceWithConfigure = &observabilityCredentialResource{}
_ resource.ResourceWithImportState = &observabilityCredentialResource{}
)
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"`
}
// NewObservabilityCredentialResource is a helper function to simplify the provider implementation.
func NewObservabilityCredentialResource() resource.Resource {
return &observabilityCredentialResource{}
}
// observabilityCredentialResource is the resource implementation.
type observabilityCredentialResource struct {
client *loadbalancer.APIClient
}
// Metadata returns the resource type name.
func (r *observabilityCredentialResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_loadbalancer_observability_credential"
}
// 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.
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.Region),
)
}
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 *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`\".",
"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.",
}
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(),
},
},
"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 *observabilityCredentialResource) 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.GetServiceStatus(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 load balancer functionality is not enabled, enable it
if *statusResp.Status != wait.FunctionalityStatusReady {
_, err = r.client.EnableService(ctx, projectId).XRequestID(uuid.NewString()).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling load balancer functionality", fmt.Sprintf("Calling API: %v", err))
return
}
_, err := wait.EnableServiceWaitHandler(ctx, r.client, projectId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling load balancer functionality", fmt.Sprintf("Waiting for enablement: %v", err))
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating observability credential", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new observability credentials
createResp, err := r.client.CreateCredentials(ctx, projectId).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
}
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 observability 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 observability credential created")
}
// Read refreshes the Terraform state with the latest data.
func (r *observabilityCredentialResource) 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 observability 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 observability 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 observability credential read")
}
func (r *observabilityCredentialResource) 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 observability credential", "Observability credential can't be updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *observabilityCredentialResource) 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 observability credential", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "Load balancer observability 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 *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] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing observability 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 observability 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
}

View file

@ -0,0 +1,152 @@
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)
}
}
})
}
}

View file

@ -16,6 +16,7 @@ import (
dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone"
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"
logMeInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/instance"
mariaDBCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/credential"
@ -380,6 +381,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
dnsRecordSet.NewRecordSetResource,
loadBalancer.NewLoadBalancerResource,
loadBalancerCredential.NewCredentialResource,
loadBalancerObservabilityCredential.NewObservabilityCredentialResource,
logMeInstance.NewInstanceResource,
logMeCredential.NewCredentialResource,
mariaDBInstance.NewInstanceResource,