diff --git a/docs/data-sources/alb_certificate.md b/docs/data-sources/alb_certificate.md new file mode 100644 index 000000000..c60fdf8b5 --- /dev/null +++ b/docs/data-sources/alb_certificate.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_alb_certificate Data Source - stackit" +subcategory: "" +description: |- + ALB TLS Certificate data source schema. Must have a region specified in the provider configuration. +--- + +# stackit_alb_certificate (Data Source) + +ALB TLS Certificate data source schema. Must have a region specified in the provider configuration. + +## Example Usage + +```terraform +data "stackit_alb_certificate" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + cert_id = "example-certificate-v1-dfa816b3184f63f43d918ea5f9493f5359f6c2404b69afbb0b60fb1af69d0bc0" +} +``` + + +## Schema + +### Required + +- `cert_id` (String) The ID of the certificate. +- `project_id` (String) STACKIT project ID to which the certificate is associated. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as `project_id`,`region`,`cert_id`. +- `name` (String) Certificate name. +- `public_key` (String) The PEM encoded public key part +- `region` (String) The resource region (e.g. eu01). If not defined, the provider region is used. diff --git a/docs/index.md b/docs/index.md index 1553f0e00..ebf478219 100644 --- a/docs/index.md +++ b/docs/index.md @@ -163,6 +163,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de ### Optional +- `alb_certificates_custom_endpoint` (String) Custom endpoint for the Application Load Balancer TLS Certificate service - `alb_custom_endpoint` (String) Custom endpoint for the Application Load Balancer service - `authorization_custom_endpoint` (String) Custom endpoint for the Membership service - `cdn_custom_endpoint` (String) Custom endpoint for the CDN service diff --git a/docs/resources/alb_certificate.md b/docs/resources/alb_certificate.md new file mode 100644 index 000000000..95b84e096 --- /dev/null +++ b/docs/resources/alb_certificate.md @@ -0,0 +1,76 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_alb_certificate Resource - stackit" +subcategory: "" +description: |- + Setting up supporting infrastructure + The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the the automatic creation of a TLS certificate resource. +--- + +# stackit_alb_certificate (Resource) + +## Setting up supporting infrastructure + + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the the automatic creation of a TLS certificate resource. + +## Example Usage + +```terraform +variable "project_id" { + description = "The STACKIT Project ID" + type = string + default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# Create a RAS key pair +resource "tls_private_key" "example" { + algorithm = "RSA" + rsa_bits = 2048 +} + +# Create a TLS certificate +resource "tls_self_signed_cert" "example" { + private_key_pem = tls_private_key.example.private_key_pem + + subject { + common_name = "localhost" + organization = "Stackit Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +# Create a ALB certificate +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = "example-certificate" + private_key = tls_private_key.example.private_key_pem + public_key = tls_self_signed_cert.example.cert_pem +} +``` + + +## Schema + +### Required + +- `name` (String) Certificate name. +- `private_key` (String, Sensitive) The PEM encoded private key part +- `project_id` (String) STACKIT project ID to which the certificate is associated. +- `public_key` (String) The PEM encoded public key part + +### Optional + +- `region` (String) The resource region (e.g. eu01). If not defined, the provider region is used. + +### Read-Only + +- `cert_id` (String) The ID of the certificate. +- `id` (String) Terraform's internal resource ID. It is structured as `project_id`,`region`,`cert_id`. diff --git a/docs/resources/application_load_balancer.md b/docs/resources/application_load_balancer.md index bd05ba1b2..2e990e5dd 100644 --- a/docs/resources/application_load_balancer.md +++ b/docs/resources/application_load_balancer.md @@ -107,7 +107,7 @@ resource "stackit_server" "server" { } # Create example credentials for observability of the ALB -# Create real credentials in your stackit observability +# Create real credentials in your STACKIT observability resource "stackit_loadbalancer_observability_credential" "observability" { project_id = var.project_id display_name = "my-cred" @@ -115,6 +115,38 @@ resource "stackit_loadbalancer_observability_credential" "observability" { username = "username" } +# Create a RAS key pair +resource "tls_private_key" "example" { + algorithm = "RSA" + rsa_bits = 2048 +} + +# Create a TLS certificate +resource "tls_self_signed_cert" "example" { + private_key_pem = tls_private_key.example.private_key_pem + + subject { + common_name = "localhost" + organization = "STACKIT Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +# Create a ALB certificate +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = "example-certificate" + private_key = tls_private_key.example.private_key_pem + public_key = tls_self_signed_cert.example.cert_pem +} + # Create a Application Load Balancer resource "stackit_application_load_balancer" "example" { project_id = var.project_id @@ -156,9 +188,7 @@ resource "stackit_application_load_balancer" "example" { https = { certificate_config = { certificate_ids = [ - # Currently no TF provider available, needs to be added with API - # https://docs.api.stackit.cloud/documentation/certificates/version/v2 - "name-v1-8c81bd317af8a03b8ef0851ccb074eb17d1ad589b540446244a5e593f78ef820" + stackit_alb_certificate.certificate.cert_id ] } } diff --git a/examples/data-sources/stackit_alb_certificate/data-source.tf b/examples/data-sources/stackit_alb_certificate/data-source.tf new file mode 100644 index 000000000..a7e540dbc --- /dev/null +++ b/examples/data-sources/stackit_alb_certificate/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_alb_certificate" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + cert_id = "example-certificate-v1-dfa816b3184f63f43d918ea5f9493f5359f6c2404b69afbb0b60fb1af69d0bc0" +} diff --git a/examples/resources/stackit_alb_certificate/resource.tf b/examples/resources/stackit_alb_certificate/resource.tf new file mode 100644 index 000000000..2f3994563 --- /dev/null +++ b/examples/resources/stackit_alb_certificate/resource.tf @@ -0,0 +1,37 @@ +variable "project_id" { + description = "The STACKIT Project ID" + type = string + default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# Create a RAS key pair +resource "tls_private_key" "example" { + algorithm = "RSA" + rsa_bits = 2048 +} + +# Create a TLS certificate +resource "tls_self_signed_cert" "example" { + private_key_pem = tls_private_key.example.private_key_pem + + subject { + common_name = "localhost" + organization = "Stackit Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +# Create a ALB certificate +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = "example-certificate" + private_key = tls_private_key.example.private_key_pem + public_key = tls_self_signed_cert.example.cert_pem +} \ No newline at end of file diff --git a/examples/resources/stackit_application_load_balancer/resource.tf b/examples/resources/stackit_application_load_balancer/resource.tf index 276eef545..808f49394 100644 --- a/examples/resources/stackit_application_load_balancer/resource.tf +++ b/examples/resources/stackit_application_load_balancer/resource.tf @@ -88,7 +88,7 @@ resource "stackit_server" "server" { } # Create example credentials for observability of the ALB -# Create real credentials in your stackit observability +# Create real credentials in your STACKIT observability resource "stackit_loadbalancer_observability_credential" "observability" { project_id = var.project_id display_name = "my-cred" @@ -96,6 +96,38 @@ resource "stackit_loadbalancer_observability_credential" "observability" { username = "username" } +# Create a RAS key pair +resource "tls_private_key" "example" { + algorithm = "RSA" + rsa_bits = 2048 +} + +# Create a TLS certificate +resource "tls_self_signed_cert" "example" { + private_key_pem = tls_private_key.example.private_key_pem + + subject { + common_name = "localhost" + organization = "STACKIT Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +# Create a ALB certificate +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = "example-certificate" + private_key = tls_private_key.example.private_key_pem + public_key = tls_self_signed_cert.example.cert_pem +} + # Create a Application Load Balancer resource "stackit_application_load_balancer" "example" { project_id = var.project_id @@ -137,9 +169,7 @@ resource "stackit_application_load_balancer" "example" { https = { certificate_config = { certificate_ids = [ - # Currently no TF provider available, needs to be added with API - # https://docs.api.stackit.cloud/documentation/certificates/version/v2 - "name-v1-8c81bd317af8a03b8ef0851ccb074eb17d1ad589b540446244a5e593f78ef820" + stackit_alb_certificate.certificate.cert_id ] } } diff --git a/go.mod b/go.mod index 64fc40a54..0f1c74586 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.24.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.13.0 + github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.19.1 github.com/stackitcloud/stackit-sdk-go/services/edge v0.7.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.11.0 diff --git a/go.sum b/go.sum index 2b4b8674a..cc4f40ea7 100644 --- a/go.sum +++ b/go.sum @@ -163,6 +163,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0 h1:HxPgBu0 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0/go.mod h1:uYI9pHAA2g84jJN25ejFUxa0/JtfpPZqMDkctQ1BzJk= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.13.0 h1:iRJK2d3I2QqWp8hqhxlkCtQDNb7fwKHkik9ogmcx2o8= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.13.0/go.mod h1:URWWMIbvq4YgWdGYCbccr3eat4Y+0qRpufZsEAsvoLM= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 h1:RBY/mNR4H8Vd/7z0nky+AQNvoaZ16hvrGSuYi1YLLao= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1/go.mod h1:3R/RwYdBc1s6WZNhToWs0rBDropbNRM7okOAdjY3rpU= github.com/stackitcloud/stackit-sdk-go/services/dns v0.19.1 h1:VfszhFq/Snsd0LnflS8PbM0d9cG98hOFpamfjlcTnDQ= github.com/stackitcloud/stackit-sdk-go/services/dns v0.19.1/go.mod h1:gBv6YkB3Xf3c0ZXg2GwtWY8zExwGPF/Ag114XiiERxg= github.com/stackitcloud/stackit-sdk-go/services/edge v0.7.0 h1:DNBiHWQEWXHSbaZBmnXb+CaPXX1uVsSfp4FTHoH4wrM= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index acc566f1c..308cfe738 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -41,6 +41,7 @@ type ProviderData struct { // Deprecated: Use DefaultRegion instead Region string DefaultRegion string + ALBCertificatesCustomEndpoint string ALBCustomEndpoint string AuthorizationCustomEndpoint string CdnCustomEndpoint string diff --git a/stackit/internal/services/albcertificates/certificate/datasource.go b/stackit/internal/services/albcertificates/certificate/datasource.go new file mode 100644 index 000000000..69362876b --- /dev/null +++ b/stackit/internal/services/albcertificates/certificate/datasource.go @@ -0,0 +1,151 @@ +package certificate + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + certUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/albcertificates/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &certDataSource{} +) + +// NewCertificatesDataSource is a helper function to simplify the provider implementation. +func NewCertificatesDataSource() datasource.DataSource { + return &certDataSource{} +} + +// certDataSource is the data source implementation. +type certDataSource struct { + client *certSdk.APIClient + providerData core.ProviderData +} + +// Metadata returns the data source type name. +func (r *certDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alb_certificate" +} + +// Configure adds the provider configured client to the data source. +func (r *certDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := certUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Certificate client configured") +} + +// Schema defines the schema for the resource. +func (r *certDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Certificates resource schema.", + "id": "Terraform's internal resource ID. It is structured as `project_id`,`region`,`cert_id`.", + "project_id": "STACKIT project ID to which the certificate is associated.", + "region": "The resource region (e.g. eu01). If not defined, the provider region is used.", + "cert_id": "The ID of the certificate.", + "name": "Certificate name.", + "private_key": "The PEM encoded private key part", + "public_key": "The PEM encoded public key part", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + MarkdownDescription: `ALB TLS Certificate data source schema. Must have a region specified in the provider configuration. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + }, + "cert_id": schema.StringAttribute{ + Description: descriptions["cert_id"], + Required: true, + }, + "public_key": schema.StringAttribute{ + Description: descriptions["public_key"], + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *certDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model DataSourceModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + certId := model.CertID.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "cert_id", certId) + + certResp, err := r.client.DefaultAPI.GetCertificate(ctx, projectId, region, certId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading certificate", + fmt.Sprintf("Certificate with ID %q does not exist in project %q.", certId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapDataFields(certResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading certificate", 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, "Certificate read") +} diff --git a/stackit/internal/services/albcertificates/certificate/resource.go b/stackit/internal/services/albcertificates/certificate/resource.go new file mode 100644 index 000000000..6581eb9a5 --- /dev/null +++ b/stackit/internal/services/albcertificates/certificate/resource.go @@ -0,0 +1,413 @@ +package certificate + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/oapierror" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + certUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/albcertificates/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &certificatesResource{} + _ resource.ResourceWithConfigure = &certificatesResource{} + _ resource.ResourceWithImportState = &certificatesResource{} + _ resource.ResourceWithModifyPlan = &certificatesResource{} +) + +// DataSourceModel - Base fields shared by both Resource and Data Source +type DataSourceModel struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + CertID types.String `tfsdk:"cert_id"` + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` +} + +// Model - For Resource includes the PrivateKey +type Model struct { + DataSourceModel + PrivateKey types.String `tfsdk:"private_key"` +} + +// NewCertificatesResource is a helper function to simplify the provider implementation. +func NewCertificatesResource() resource.Resource { + return &certificatesResource{} +} + +// certificatesResource is the resource implementation. +type certificatesResource struct { + client *certSdk.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *certificatesResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alb_certificate" +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *certificatesResource) 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 *certificatesResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := certUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Certificate client configured") +} + +// Schema defines the schema for the resource. +func (r *certificatesResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Certificates resource schema.", + "id": "Terraform's internal resource ID. It is structured as `project_id`,`region`,`cert_id`.", + "project_id": "STACKIT project ID to which the certificate is associated.", + "region": "The resource region (e.g. eu01). If not defined, the provider region is used.", + "cert_id": "The ID of the certificate.", + "name": "Certificate name.", + "private_key": "The PEM encoded private key part", + "public_key": "The PEM encoded public key part", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + MarkdownDescription: ` +## Setting up supporting infrastructure` + "\n" + ` + +The example below creates the supporting infrastructure using the STACKIT Terraform provider, including the the automatic creation of a TLS certificate resource. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$`), + "1-63 characters [0-9] & [a-z] also [-] but not at the beginning or end", + ), + }, + }, + "cert_id": schema.StringAttribute{ + Description: descriptions["cert_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "private_key": schema.StringAttribute{ + Description: descriptions["private_key"], + Required: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtMost(8192), + }, + }, + "public_key": schema.StringAttribute{ + Description: descriptions["public_key"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtMost(8192), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *certificatesResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + 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) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Certificate", fmt.Sprintf("Payload for create: %v", err)) + return + } + + // Create a new Certificate + createResp, err := r.client.DefaultAPI.CreateCertificate(ctx, projectId, region).CreateCertificatePayload(*payload).Execute() + if err != nil { + errStr := utils.PrettyApiErr(ctx, &resp.Diagnostics, err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Certificate", fmt.Sprintf("Calling API for create: %v", errStr)) + return + } + ctx = core.LogResponse(ctx) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ + "project_id": projectId, + "cert_id": *createResp.Id, + "region": region, + }) + if resp.Diagnostics.HasError() { + return + } + + // Map response body to schema + err = mapFields(createResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Certificate", fmt.Sprintf("Processing API payload: %v", err)) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Certificate created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *certificatesResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + certId := model.CertID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "cert_id", certId) + + readResp, err := r.client.DefaultAPI.GetCertificate(ctx, projectId, region, certId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + errStr := utils.PrettyApiErr(ctx, &resp.Diagnostics, err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Certificate", fmt.Sprintf("Calling API: %v", errStr)) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapFields(readResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Certificate", 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, "Certificate read") +} + +func (r *certificatesResource) 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 certificate", "Certificates can't be updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *certificatesResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + certId := model.CertID.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "cert_id", certId) + ctx = tflog.SetField(ctx, "region", region) + + // Delete Certificate + _, err := r.client.DefaultAPI.DeleteCertificate(ctx, projectId, region, certId).Execute() + if err != nil { + errStr := utils.PrettyApiErr(ctx, &resp.Diagnostics, err) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Certificate", fmt.Sprintf("Calling API for delete: %v", errStr)) + return + } + + ctx = core.LogResponse(ctx) + + tflog.Info(ctx, "Certificate deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id, region, cert_id +func (r *certificatesResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing Certificate", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[cert_id] Got: %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ + "project_id": idParts[0], + "region": idParts[1], + "cert_id": idParts[2], + }) + tflog.Info(ctx, "Certificate state imported") +} + +// toCreatePayload and all other toX functions in this file turn a Terraform Certificate model into a createCertificate to be used with the Certificate API. +func toCreatePayload(model *Model) (*certSdk.CreateCertificatePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &certSdk.CreateCertificatePayload{ + Name: conversion.StringValueToPointer(model.Name), + PrivateKey: conversion.StringValueToPointer(model.PrivateKey), + PublicKey: conversion.StringValueToPointer(model.PublicKey), + }, nil +} + +func mapFields(cert *certSdk.GetCertificateResponse, m *Model, region string) error { + if m == nil { + return fmt.Errorf("model input is nil") + } + return mapDataFields(cert, &m.DataSourceModel, region) +} + +// mapFields and all other map functions in this file translate an API resource into a Terraform model. +func mapDataFields(cert *certSdk.GetCertificateResponse, m *DataSourceModel, region string) error { + if cert == nil { + return fmt.Errorf("response input is nil") + } + if m == nil { + return fmt.Errorf("model input is nil") + } + + var certID string + if m.CertID.ValueString() != "" { + certID = m.CertID.ValueString() + } else if cert.Id != nil { + certID = *cert.Id + } else { + return fmt.Errorf("cert ID not present") + } + m.Region = types.StringValue(region) + m.CertID = types.StringValue(certID) + m.Id = utils.BuildInternalTerraformId(m.ProjectId.ValueString(), m.Region.ValueString(), certID) + m.Name = types.StringPointerValue(cert.Name) + m.PublicKey = types.StringPointerValue(cert.PublicKey) + + return nil +} diff --git a/stackit/internal/services/albcertificates/certificate/resource_test.go b/stackit/internal/services/albcertificates/certificate/resource_test.go new file mode 100644 index 000000000..07e8e3051 --- /dev/null +++ b/stackit/internal/services/albcertificates/certificate/resource_test.go @@ -0,0 +1,205 @@ +package certificate + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +const ( + projectID = "b8c3fbaa-3ab4-4a8e-9584-de22453d046f" + region = "eu01" + certName = "example-cert-2" + certID = "example-cert-2-v1-dfa816b3184f63f43d918ea5f9493f5359f6c2404b69afbb0b60fb1af69d0bc0" + tfID = projectID + "," + region + "," + certID + certPrivateKey = "dummy-private-pem-key" + certPublicKey = "dummy-public-pem-key" +) + +func fixtureModel(mods ...func(m *Model)) *Model { + resp := &Model{ + DataSourceModel: DataSourceModel{ + Id: types.StringValue(tfID), + ProjectId: types.StringValue(projectID), + Region: types.StringValue(region), + CertID: types.StringValue(certID), + Name: types.StringValue(certName), + PublicKey: types.StringValue(certPublicKey), + }, + PrivateKey: types.StringValue(certPrivateKey), + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureModelNull(mods ...func(m *Model)) *Model { + resp := &Model{ + DataSourceModel: DataSourceModel{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + Name: types.StringNull(), + Region: types.StringNull(), + }, + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func fixtureCertificate(mods ...func(c *certSdk.GetCertificateResponse)) *certSdk.GetCertificateResponse { + resp := &certSdk.GetCertificateResponse{ + Id: utils.Ptr(certID), + Name: utils.Ptr(certName), + PublicKey: utils.Ptr(certPublicKey), + Region: utils.Ptr(region), + } + for _, mod := range mods { + mod(resp) + } + return resp +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *certSdk.CreateCertificatePayload + isValid bool + }{ + { + description: "valid", + input: fixtureModel(), + expected: &certSdk.CreateCertificatePayload{ + Name: utils.Ptr(certName), + PrivateKey: utils.Ptr(certPrivateKey), + PublicKey: utils.Ptr(certPublicKey), + }, + isValid: true, + }, + { + description: "valid empty", + input: fixtureModelNull(), + expected: &certSdk.CreateCertificatePayload{}, + isValid: true, + }, + { + description: "model nil", + input: nil, + expected: nil, + isValid: 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) { + const testRegion = "eu01" + tests := []struct { + description string + input *certSdk.GetCertificateResponse + output *Model + region string + expected *Model + isValid bool + }{ + { + description: "valid full model", + input: fixtureCertificate(), + output: &Model{ + DataSourceModel: DataSourceModel{ProjectId: types.StringValue(projectID)}, + }, + region: testRegion, + expected: fixtureModel(func(m *Model) { + m.PrivateKey = types.StringNull() + }), + isValid: true, + }, + { + description: "error input nil", + input: nil, + output: &Model{ + DataSourceModel: DataSourceModel{ProjectId: types.StringValue(projectID)}, + }, + region: testRegion, + expected: fixtureModel(), + isValid: false, + }, + { + description: "error model nil", + input: fixtureCertificate(), + output: nil, + region: testRegion, + expected: fixtureModel(), + isValid: false, + }, + { + description: "error no cert ID", + input: fixtureCertificate(func(m *certSdk.GetCertificateResponse) { + m.Id = nil + }), + output: &Model{ + DataSourceModel: DataSourceModel{ + ProjectId: types.StringValue(projectID), + CertID: types.StringValue(""), + }, + }, + region: testRegion, + expected: fixtureModel(), + isValid: false, + }, + { + description: "valid name in model", + input: fixtureCertificate(), + output: &Model{ + DataSourceModel: DataSourceModel{ + ProjectId: types.StringValue(projectID), + CertID: types.StringValue(certID), + }, + }, + region: testRegion, + expected: fixtureModel(func(m *Model) { + m.PrivateKey = types.StringNull() + }), + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, tt.output, tt.region) + 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(tt.output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/albcertificates/certificate_acc_test.go b/stackit/internal/services/albcertificates/certificate_acc_test.go new file mode 100644 index 000000000..429048060 --- /dev/null +++ b/stackit/internal/services/albcertificates/certificate_acc_test.go @@ -0,0 +1,276 @@ +package alb_test + +import ( + "context" + _ "embed" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + stackitSdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testfiles/resource-min.tf +var resourceMinConfig string + +//go:embed testfiles/resource-max.tf +var resourceMaxConfig string + +var testConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "cert_name": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), +} + +var testConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "cert_name": config.StringVariable(fmt.Sprintf("tf-acc-l%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), +} + +func TestAccCertResourceMin(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ExternalProviders: map[string]resource.ExternalProvider{ + "tls": { + Source: "hashicorp/tls", + VersionConstraint: "4.0.4", // Use a specific version to avoid lock issues + }, + }, + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckCertDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMin, + Config: testutil.CertProviderConfig() + resourceMinConfig, + Check: resource.ComposeAggregateTestCheckFunc( + // ALB Certificate instance resource + resource.TestCheckResourceAttr("stackit_alb_certificate.certificate", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("stackit_alb_certificate.certificate", "name", testutil.ConvertConfigVariable(testConfigVarsMin["cert_name"])), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "public_key"), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "private_key"), + resource.TestCheckResourceAttrPair("stackit_alb_certificate.certificate", "private_key", "tls_self_signed_cert.test", "private_key_pem"), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "region"), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "id"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMin, + Config: fmt.Sprintf(` + %s + + data "stackit_alb_certificate" "certificate" { + project_id = stackit_alb_certificate.certificate.project_id + cert_id = stackit_alb_certificate.certificate.cert_id + } + `, + testutil.CertProviderConfig()+resourceMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // ALB Certificate instance + resource.TestCheckResourceAttr("data.stackit_alb_certificate.certificate", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("data.stackit_alb_certificate.certificate", "name", testutil.ConvertConfigVariable(testConfigVarsMin["cert_name"])), + resource.TestCheckResourceAttrSet("data.stackit_alb_certificate.certificate", "public_key"), + resource.TestCheckResourceAttrSet("data.stackit_alb_certificate.certificate", "region"), + resource.TestCheckResourceAttrSet("data.stackit_alb_certificate.certificate", "id"), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "project_id", + "stackit_alb_certificate.certificate", "project_id", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "region", + "stackit_alb_certificate.certificate", "region", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "name", + "stackit_alb_certificate.certificate", "name", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "public_key", + "stackit_alb_certificate.certificate", "public_key", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMin, + ResourceName: "stackit_alb_certificate.certificate", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_alb_certificate.certificate"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_alb_certificate.certificate") + } + certID, ok := r.Primary.Attributes["cert_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, region, certID), nil + }, + ImportState: true, + ImportStateVerify: true, + // Ignore the sensitive field during verification, because the API doesn't return the key + ImportStateVerifyIgnore: []string{"private_key"}, + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccCertResourceMax(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ExternalProviders: map[string]resource.ExternalProvider{ + "tls": { + Source: "hashicorp/tls", + VersionConstraint: "4.0.4", // Use a specific version to avoid lock issues + }, + }, + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckCertDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMax, + Config: testutil.CertProviderConfig() + resourceMaxConfig, + Check: resource.ComposeAggregateTestCheckFunc( + // ALB Certificate instance resource + resource.TestCheckResourceAttr("stackit_alb_certificate.certificate", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_alb_certificate.certificate", "name", testutil.ConvertConfigVariable(testConfigVarsMax["cert_name"])), + resource.TestCheckResourceAttr("stackit_alb_certificate.certificate", "region", testutil.ConvertConfigVariable(testConfigVarsMax["region"])), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "public_key"), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "private_key"), + resource.TestCheckResourceAttrPair("stackit_alb_certificate.certificate", "private_key", "tls_self_signed_cert.test", "private_key_pem"), + resource.TestCheckResourceAttrSet("stackit_alb_certificate.certificate", "id"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMax, + Config: fmt.Sprintf(` + %s + + data "stackit_alb_certificate" "certificate" { + project_id = stackit_alb_certificate.certificate.project_id + cert_id = stackit_alb_certificate.certificate.cert_id + } + `, + testutil.CertProviderConfig()+resourceMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // ALB Certificate instance + resource.TestCheckResourceAttr("data.stackit_alb_certificate.certificate", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("data.stackit_alb_certificate.certificate", "name", testutil.ConvertConfigVariable(testConfigVarsMax["cert_name"])), + resource.TestCheckResourceAttr("data.stackit_alb_certificate.certificate", "region", testutil.ConvertConfigVariable(testConfigVarsMax["region"])), + resource.TestCheckResourceAttrSet("data.stackit_alb_certificate.certificate", "public_key"), + resource.TestCheckResourceAttrSet("data.stackit_alb_certificate.certificate", "id"), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "project_id", + "stackit_alb_certificate.certificate", "project_id", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "region", + "stackit_alb_certificate.certificate", "region", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "name", + "stackit_alb_certificate.certificate", "name", + ), + resource.TestCheckResourceAttrPair( + "data.stackit_alb_certificate.certificate", "public_key", + "stackit_alb_certificate.certificate", "public_key", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMax, + ResourceName: "stackit_alb_certificate.certificate", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_alb_certificate.certificate"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_alb_certificate.certificate") + } + certID, ok := r.Primary.Attributes["cert_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, region, certID), nil + }, + ImportState: true, + ImportStateVerify: true, + // Ignore the sensitive field during verification, because the API doesn't return the key + ImportStateVerifyIgnore: []string{"private_key"}, + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckCertDestroy(s *terraform.State) error { + ctx := context.Background() + var client *certSdk.APIClient + var err error + if testutil.ALBCustomEndpoint == "" { + client, err = certSdk.NewAPIClient() + } else { + client, err = certSdk.NewAPIClient( + stackitSdkConfig.WithEndpoint(testutil.CertCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + region := "eu01" + if testutil.Region != "" { + region = testutil.Region + } + certificateToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_loadbalancer" { + continue + } + // cetificate terraform ID: = "[project_id],[region],[name]" + certificateName := strings.Split(rs.Primary.ID, core.Separator)[1] + certificateToDestroy = append(certificateToDestroy, certificateName) + } + + certificateResp, err := client.DefaultAPI.ListCertificates(ctx, testutil.ProjectId, region).Execute() + if err != nil { + return fmt.Errorf("getting certificateResp: %w", err) + } + + if certificateResp.Items == nil || (certificateResp.Items != nil && len(certificateResp.Items) == 0) { + fmt.Print("No certificates found for project \n") + return nil + } + + items := certificateResp.Items + for i := range items { + if items[i].Name == nil { + continue + } + if utils.Contains(certificateToDestroy, *items[i].Name) { + _, err := client.DefaultAPI.DeleteCertificate(ctx, testutil.ProjectId, region, *items[i].Id).Execute() + if err != nil { + return fmt.Errorf("destroying certificate %s during CheckDestroy: %w", *items[i].Name, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/albcertificates/testfiles/resource-max.tf b/stackit/internal/services/albcertificates/testfiles/resource-max.tf new file mode 100644 index 000000000..aa1231f94 --- /dev/null +++ b/stackit/internal/services/albcertificates/testfiles/resource-max.tf @@ -0,0 +1,34 @@ + +variable "project_id" {} +variable "cert_name" {} +variable "region" {} + +resource "tls_private_key" "test" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "test" { + private_key_pem = tls_private_key.test.private_key_pem + + subject { + common_name = "localhost" + organization = "Stackit Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = var.cert_name + region = var.region + private_key = tls_private_key.test.private_key_pem + public_key = tls_self_signed_cert.test.cert_pem +} diff --git a/stackit/internal/services/albcertificates/testfiles/resource-min.tf b/stackit/internal/services/albcertificates/testfiles/resource-min.tf new file mode 100644 index 000000000..766006d2d --- /dev/null +++ b/stackit/internal/services/albcertificates/testfiles/resource-min.tf @@ -0,0 +1,32 @@ + +variable "project_id" {} +variable "cert_name" {} + +resource "tls_private_key" "test" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "test" { + private_key_pem = tls_private_key.test.private_key_pem + + subject { + common_name = "localhost" + organization = "Stackit Test" + } + + validity_period_hours = 12 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +resource "stackit_alb_certificate" "certificate" { + project_id = var.project_id + name = var.cert_name + private_key = tls_private_key.test.private_key_pem + public_key = tls_self_signed_cert.test.cert_pem +} diff --git a/stackit/internal/services/albcertificates/utils/util.go b/stackit/internal/services/albcertificates/utils/util.go new file mode 100644 index 000000000..656caca3c --- /dev/null +++ b/stackit/internal/services/albcertificates/utils/util.go @@ -0,0 +1,29 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *certSdk.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.ALBCertificatesCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ALBCertificatesCustomEndpoint)) + } + apiClient, err := certSdk.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "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 nil + } + + return apiClient +} diff --git a/stackit/internal/services/albcertificates/utils/util_test.go b/stackit/internal/services/albcertificates/utils/util_test.go new file mode 100644 index 000000000..f100229c0 --- /dev/null +++ b/stackit/internal/services/albcertificates/utils/util_test.go @@ -0,0 +1,93 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + certSdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "1.2.3" + testCustomEndpoint = "https://alb-cert-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + /* mock authentication by setting service account token env variable */ + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + } + tests := []struct { + name string + args args + wantErr bool + expected *certSdk.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *certSdk.APIClient { + apiClient, err := certSdk.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + ALBCertificatesCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *certSdk.APIClient { + apiClient, err := certSdk.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 6e654c410..913926bee 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -68,6 +68,7 @@ var ( ALBCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CUSTOM_ENDPOINT", providerName: "alb_custom_endpoint"} CdnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_CDN_CUSTOM_ENDPOINT", providerName: "cdn_custom_endpoint"} + CertCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_CERT_CUSTOM_ENDPOINT", providerName: "alb_certificates_custom_endpoint"} DnsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DNS_CUSTOM_ENDPOINT", providerName: "dns_custom_endpoint"} EdgeCloudCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_EDGECLOUD_CUSTOM_ENDPOINT", providerName: "edgecloud_custom_endpoint"} GitCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_GIT_CUSTOM_ENDPOINT", providerName: "git_custom_endpoint"} @@ -100,6 +101,7 @@ var ( allCustomEndpoints = []customEndpointConfig{ ALBCustomEndpoint, CdnCustomEndpoint, + CertCustomEndpoint, DnsCustomEndpoint, EdgeCloudCustomEndpoint, GitCustomEndpoint, diff --git a/stackit/provider.go b/stackit/provider.go index 2ea383a1f..dc4524a5f 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -22,6 +22,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/access_token" alb "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/alb/applicationloadbalancer" + cert "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/albcertificates/certificate" customRole "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/customrole" roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments" cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain" @@ -163,6 +164,7 @@ type providerModel struct { ALBCustomEndpoint types.String `tfsdk:"alb_custom_endpoint"` AuthorizationCustomEndpoint types.String `tfsdk:"authorization_custom_endpoint"` CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"` + ALBCertificatesCustomEndpoint types.String `tfsdk:"alb_certificates_custom_endpoint"` DnsCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"` EdgeCloudCustomEndpoint types.String `tfsdk:"edgecloud_custom_endpoint"` GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"` @@ -215,6 +217,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "oidc_request_token": "The bearer token for the request to the OIDC provider. For use when authenticating as a Service Account using OpenID Connect.", "region": "Region will be used as the default location for regional services. Not all services require a region, some are global", "default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global", + "alb_certificates_custom_endpoint": "Custom endpoint for the Application Load Balancer TLS Certificate service", "alb_custom_endpoint": "Custom endpoint for the Application Load Balancer service", "cdn_custom_endpoint": "Custom endpoint for the CDN service", "dns_custom_endpoint": "Custom endpoint for the DNS service", @@ -335,6 +338,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["cdn_custom_endpoint"], }, + "alb_certificates_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["alb_certificates_custom_endpoint"], + }, "dns_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["dns_custom_endpoint"], @@ -502,6 +509,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.Region, func(v string) { providerData.Region = v }) // nolint:staticcheck // preliminary handling of deprecated attribute setBoolField(providerConfig.EnableBetaResources, func(v bool) { providerData.EnableBetaResources = v }) + setStringField(providerConfig.ALBCertificatesCustomEndpoint, func(v string) { providerData.ALBCertificatesCustomEndpoint = v }) setStringField(providerConfig.ALBCustomEndpoint, func(v string) { providerData.ALBCustomEndpoint = v }) setStringField(providerConfig.AuthorizationCustomEndpoint, func(v string) { providerData.AuthorizationCustomEndpoint = v }) setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v }) @@ -609,6 +617,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource alb.NewApplicationLoadBalancerDataSource, alertGroup.NewAlertGroupDataSource, cdn.NewDistributionDataSource, + cert.NewCertificatesDataSource, cdnCustomDomain.NewCustomDomainDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, @@ -700,6 +709,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { alb.NewApplicationLoadBalancerResource, alertGroup.NewAlertGroupResource, cdn.NewDistributionResource, + cert.NewCertificatesResource, cdnCustomDomain.NewCustomDomainResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource,