From df0f15215814b2e00e953621a6e83f5a0a3f1c1e Mon Sep 17 00:00:00 2001 From: Politano <47066134+matheuspolitano@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:27:22 +0200 Subject: [PATCH] feat(cdn): add custom certificate support (#983) relates to STACKITCDN-1000 --- docs/data-sources/cdn_custom_domain.md | 11 + docs/resources/cdn_custom_domain.md | 20 ++ .../stackit_cdn_custom_domain/resource.tf | 6 +- stackit/internal/services/cdn/cdn_acc_test.go | 108 +++++-- .../services/cdn/customdomain/datasource.go | 102 ++++++- .../cdn/customdomain/datasource_test.go | 137 +++++++++ .../services/cdn/customdomain/resource.go | 257 +++++++++++++++-- .../cdn/customdomain/resource_test.go | 264 ++++++++++++++++-- 8 files changed, 834 insertions(+), 71 deletions(-) create mode 100644 stackit/internal/services/cdn/customdomain/datasource_test.go diff --git a/docs/data-sources/cdn_custom_domain.md b/docs/data-sources/cdn_custom_domain.md index 7839b991..071e4dad 100644 --- a/docs/data-sources/cdn_custom_domain.md +++ b/docs/data-sources/cdn_custom_domain.md @@ -32,8 +32,19 @@ data "stackit_cdn_custom_domain" "example" { - `name` (String) - `project_id` (String) STACKIT project ID associated with the distribution +### Optional + +- `certificate` (Attributes) The TLS certificate for the custom domain. If omitted, a managed certificate will be used. If the block is specified, a custom certificate is used. (see [below for nested schema](#nestedatt--certificate)) + ### Read-Only - `errors` (List of String) List of distribution errors - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`". - `status` (String) Status of the distribution + + +### Nested Schema for `certificate` + +Read-Only: + +- `version` (Number) A version identifier for the certificate. Required for custom certificates. The certificate will be updated if this field is changed. diff --git a/docs/resources/cdn_custom_domain.md b/docs/resources/cdn_custom_domain.md index 7bd977a2..0a535c6b 100644 --- a/docs/resources/cdn_custom_domain.md +++ b/docs/resources/cdn_custom_domain.md @@ -20,6 +20,10 @@ resource "stackit_cdn_custom_domain" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "https://xxx.xxx" + certificate = { + certificate = "-----BEGIN CERTIFICATE-----\nY2VydGlmaWNhdGVfZGF0YQ==\n-----END CERTIFICATE---" + private_key = "-----BEGIN RSA PRIVATE KEY-----\nY2VydGlmaWNhdGVfZGF0YQ==\n-----END RSA PRIVATE KEY---" + } } # Only use the import statement, if you want to import an existing cdn custom domain @@ -38,8 +42,24 @@ import { - `name` (String) - `project_id` (String) STACKIT project ID associated with the distribution +### Optional + +- `certificate` (Attributes) The TLS certificate for the custom domain. If omitted, a managed certificate will be used. If the block is specified, a custom certificate is used. (see [below for nested schema](#nestedatt--certificate)) + ### Read-Only - `errors` (List of String) List of distribution errors - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`". - `status` (String) Status of the distribution + + +### Nested Schema for `certificate` + +Optional: + +- `certificate` (String, Sensitive) The PEM-encoded TLS certificate. Required for custom certificates. +- `private_key` (String, Sensitive) The PEM-encoded private key for the certificate. Required for custom certificates. The certificate will be updated if this field is changed. + +Read-Only: + +- `version` (Number) A version identifier for the certificate. Required for custom certificates. The certificate will be updated if this field is changed. diff --git a/examples/resources/stackit_cdn_custom_domain/resource.tf b/examples/resources/stackit_cdn_custom_domain/resource.tf index 9f5f6f36..68ddfb96 100644 --- a/examples/resources/stackit_cdn_custom_domain/resource.tf +++ b/examples/resources/stackit_cdn_custom_domain/resource.tf @@ -2,10 +2,14 @@ resource "stackit_cdn_custom_domain" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "https://xxx.xxx" + certificate = { + certificate = "-----BEGIN CERTIFICATE-----\nY2VydGlmaWNhdGVfZGF0YQ==\n-----END CERTIFICATE---" + private_key = "-----BEGIN RSA PRIVATE KEY-----\nY2VydGlmaWNhdGVfZGF0YQ==\n-----END RSA PRIVATE KEY---" + } } # Only use the import statement, if you want to import an existing cdn custom domain import { to = stackit_cdn_custom_domain.import-example id = "${var.project_id},${var.distribution_id},${var.custom_domain_name}" -} \ No newline at end of file +} diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index b723f5d8..c2feec3e 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -2,7 +2,13 @@ package cdn_test import ( "context" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" + "math/big" "net" "strings" "testing" @@ -26,6 +32,7 @@ var instanceResource = map[string]string{ "config_regions_updated": "\"EU\", \"US\", \"ASIA\"", "blocked_countries": "\"CU\", \"AQ\"", // Do NOT use DE or AT here, because the request might be blocked by bunny at the time of creation - don't lock yourself out "custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs + "dns_name": fmt.Sprintf("tf-acc-%s.stackit.gg", strings.Split(uuid.NewString(), "-")[0]), } func configResources(regions string) string { @@ -51,7 +58,7 @@ func configResources(regions string) string { resource "stackit_dns_zone" "dns_zone" { project_id = "%s" name = "cdn_acc_test_zone" - dns_name = "cdntestzone.stackit.gg" + dns_name = "%s" contact_email = "aa@bb.cc" type = "primary" default_ttl = 3600 @@ -64,40 +71,87 @@ func configResources(regions string) string { records = ["${stackit_cdn_distribution.distribution.domains[0].name}."] } `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], - regions, instanceResource["blocked_countries"], testutil.ProjectId, + regions, instanceResource["blocked_countries"], testutil.ProjectId, instanceResource["dns_name"], testutil.ProjectId, instanceResource["custom_domain_prefix"]) } -func configCustomDomainResources(regions string) string { +func configCustomDomainResources(regions, cert, key string) string { return fmt.Sprintf(` %s resource "stackit_cdn_custom_domain" "custom_domain" { project_id = stackit_cdn_distribution.distribution.project_id distribution_id = stackit_cdn_distribution.distribution.distribution_id - name = "${stackit_dns_record_set.dns_record.name}.cdntestzone.stackit.gg" + name = "${stackit_dns_record_set.dns_record.name}.${stackit_dns_zone.dns_zone.dns_name}" + certificate = { + certificate = %q + private_key = %q + } } -`, configResources(regions)) +`, configResources(regions), cert, key) } -func configDatasources(regions string) string { +func configDatasources(regions, cert, key string) string { return fmt.Sprintf(` - %s + %s - data "stackit_cdn_distribution" "distribution" { + data "stackit_cdn_distribution" "distribution" { project_id = stackit_cdn_distribution.distribution.project_id - distribution_id = stackit_cdn_distribution.distribution.distribution_id - } - - data "stackit_cdn_custom_domain" "custom_domain" { + distribution_id = stackit_cdn_distribution.distribution.distribution_id + } + + data "stackit_cdn_custom_domain" "custom_domain" { project_id = stackit_cdn_custom_domain.custom_domain.project_id distribution_id = stackit_cdn_custom_domain.custom_domain.distribution_id name = stackit_cdn_custom_domain.custom_domain.name - } - `, configCustomDomainResources(regions)) -} + } + `, configCustomDomainResources(regions, cert, key)) +} +func makeCertAndKey(t *testing.T, organization string) (cert, key []byte) { + privateKey, err := rsa.GenerateKey(cryptoRand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate key: %s", err.Error()) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Issuer: pkix.Name{CommonName: organization}, + Subject: pkix.Name{ + Organization: []string{organization}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + cert, err = x509.CreateCertificate( + cryptoRand.Reader, + &template, + &template, + &privateKey.PublicKey, + privateKey, + ) + if err != nil { + t.Fatalf("failed to generate cert: %s", err.Error()) + } + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + }), pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) +} func TestAccCDNDistributionResource(t *testing.T) { + fullDomainName := fmt.Sprintf("%s.%s", instanceResource["custom_domain_prefix"], instanceResource["dns_name"]) + organization := fmt.Sprintf("organization-%s", uuid.NewString()) + cert, key := makeCertAndKey(t, organization) + + organization_updated := fmt.Sprintf("organization-updated-%s", uuid.NewString()) + cert_updated, key_updated := makeCertAndKey(t, organization_updated) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, CheckDestroy: testAccCheckCDNDistributionDestroy, @@ -128,16 +182,16 @@ func TestAccCDNDistributionResource(t *testing.T) { { Config: configResources(instanceResource["config_regions"]), Check: func(_ *terraform.State) error { - _, err := blockUntilDomainResolves(instanceResource["custom_domain_prefix"] + ".cdntestzone.stackit.gg") + _, err := blockUntilDomainResolves(fullDomainName) return err }, }, // Custom Domain Create { - Config: configCustomDomainResources(instanceResource["config_regions"]), + Config: configCustomDomainResources(instanceResource["config_regions"], string(cert), string(key)), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), - resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainName), resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"), ), @@ -181,17 +235,21 @@ func TestAccCDNDistributionResource(t *testing.T) { }, ImportState: true, ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "certificate.certificate", + "certificate.private_key", + }, }, // Data Source { - Config: configDatasources(instanceResource["config_regions"]), + Config: configDatasources(instanceResource["config_regions"], string(cert), string(key)), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "distribution_id"), resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "created_at"), resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "updated_at"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.#", "2"), resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "domains.0.name"), - resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.name", fullDomainName), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.type", "managed"), @@ -206,20 +264,21 @@ func TestAccCDNDistributionResource(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), - resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "certificate.version", "1"), + resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "name", fullDomainName), resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), ), }, // Update { - Config: configCustomDomainResources(instanceResource["config_regions_updated"]), + Config: configCustomDomainResources(instanceResource["config_regions_updated"], string(cert_updated), string(key_updated)), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "2"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"), - resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.name", fullDomainName), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.type", "managed"), @@ -235,7 +294,8 @@ func TestAccCDNDistributionResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), - resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "certificate.version", "2"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainName), resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"), ), diff --git a/stackit/internal/services/cdn/customdomain/datasource.go b/stackit/internal/services/cdn/customdomain/datasource.go index 19bb27f3..14946b1e 100644 --- a/stackit/internal/services/cdn/customdomain/datasource.go +++ b/stackit/internal/services/cdn/customdomain/datasource.go @@ -9,6 +9,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -27,6 +28,10 @@ var ( _ datasource.DataSourceWithConfigure = &customDomainDataSource{} ) +var certificateDataSourceTypes = map[string]attr.Type{ + "version": types.Int64Type, +} + type customDomainDataSource struct { client *cdn.APIClient } @@ -35,6 +40,16 @@ func NewCustomDomainDataSource() datasource.DataSource { return &customDomainDataSource{} } +type customDomainDataSourceModel struct { + ID types.String `tfsdk:"id"` + DistributionId types.String `tfsdk:"distribution_id"` + ProjectId types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + Status types.String `tfsdk:"status"` + Errors types.List `tfsdk:"errors"` + Certificate types.Object `tfsdk:"certificate"` +} + func (d *customDomainDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { @@ -89,12 +104,22 @@ func (r *customDomainDataSource) Schema(_ context.Context, _ datasource.SchemaRe Computed: true, Description: customDomainSchemaDescriptions["errors"], }, + "certificate": schema.SingleNestedAttribute{ + Description: certificateSchemaDescriptions["main"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "version": schema.Int64Attribute{ + Description: certificateSchemaDescriptions["version"], + Computed: true, + }, + }, + }, }, } } func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var model CustomDomainModel + var model customDomainDataSourceModel // Use the new data source model diags := req.Config.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -111,7 +136,6 @@ func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRe customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).Execute() if err != nil { var oapiErr *oapierror.GenericOpenAPIError - // n.b. err is caught here if of type *oapierror.GenericOpenAPIError, which the stackit SDK client returns if errors.As(err, &oapiErr) { if oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) @@ -121,11 +145,14 @@ func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRe core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", fmt.Sprintf("Calling API: %v", err)) return } - err = mapCustomDomainFields(customDomainResp.CustomDomain, &model) + + // Call the new data source mapping function + err = mapCustomDomainDataSourceFields(customDomainResp, &model, projectId, distributionId) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", fmt.Sprintf("Processing API payload: %v", err)) return } + // Set refreshed state diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) @@ -134,3 +161,72 @@ func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRe } tflog.Info(ctx, "CDN custom domain read") } + +func mapCustomDomainDataSourceFields(customDomainResponse *cdn.GetCustomDomainResponse, model *customDomainDataSourceModel, projectId, distributionId string) error { + if customDomainResponse == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + if customDomainResponse.CustomDomain == nil { + return fmt.Errorf("CustomDomain is missing in response") + } + + if customDomainResponse.CustomDomain.Name == nil { + return fmt.Errorf("name is missing in response") + } + if customDomainResponse.CustomDomain.Status == nil { + return fmt.Errorf("status missing in response") + } + + normalizedCert, err := normalizeCertificate(customDomainResponse.Certificate) + if err != nil { + return fmt.Errorf("Certificate error in normalizer: %w", err) + } + + // If the certificate is managed, the certificate block in the state should be null. + if normalizedCert.Type == "managed" { + model.Certificate = types.ObjectNull(certificateDataSourceTypes) + } else { + // For custom certificates, we only care about the version. + version := types.Int64Null() + if normalizedCert.Version != nil { + version = types.Int64Value(*normalizedCert.Version) + } + + certificateObj, diags := types.ObjectValue(certificateDataSourceTypes, map[string]attr.Value{ + "version": version, + }) + if diags.HasError() { + return fmt.Errorf("failed to map certificate: %w", core.DiagsToError(diags)) + } + model.Certificate = certificateObj + } + + model.ID = types.StringValue(fmt.Sprintf("%s,%s,%s", projectId, distributionId, *customDomainResponse.CustomDomain.Name)) + model.Status = types.StringValue(string(*customDomainResponse.CustomDomain.Status)) + + customDomainErrors := []attr.Value{} + if customDomainResponse.CustomDomain.Errors != nil { + for _, e := range *customDomainResponse.CustomDomain.Errors { + if e.En == nil { + return fmt.Errorf("error description missing") + } + customDomainErrors = append(customDomainErrors, types.StringValue(*e.En)) + } + } + modelErrors, diags := types.ListValue(types.StringType, customDomainErrors) + if diags.HasError() { + return core.DiagsToError(diags) + } + model.Errors = modelErrors + + // Also map the fields back to the model from the config + model.ProjectId = types.StringValue(projectId) + model.DistributionId = types.StringValue(distributionId) + model.Name = types.StringValue(*customDomainResponse.CustomDomain.Name) + + return nil +} diff --git a/stackit/internal/services/cdn/customdomain/datasource_test.go b/stackit/internal/services/cdn/customdomain/datasource_test.go new file mode 100644 index 00000000..0a823b27 --- /dev/null +++ b/stackit/internal/services/cdn/customdomain/datasource_test.go @@ -0,0 +1,137 @@ +package cdn + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +func TestMapDataSourceFields(t *testing.T) { + emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) + + // Expected certificate object when a custom certificate is returned + certAttributes := map[string]attr.Value{ + "version": types.Int64Value(3), + } + certificateObj, _ := types.ObjectValue(certificateDataSourceTypes, certAttributes) + + // Helper to create expected model instances + expectedModel := func(mods ...func(*customDomainDataSourceModel)) *customDomainDataSourceModel { + model := &customDomainDataSourceModel{ + ID: types.StringValue("test-project-id,test-distribution-id,https://testdomain.com"), + DistributionId: types.StringValue("test-distribution-id"), + ProjectId: types.StringValue("test-project-id"), + Name: types.StringValue("https://testdomain.com"), + Status: types.StringValue("ACTIVE"), + Errors: emtpyErrorsList, + Certificate: types.ObjectUnknown(certificateDataSourceTypes), + } + for _, mod := range mods { + mod(model) + } + return model + } + + // API response fixtures for custom and managed certificates + customType := "custom" + customVersion := int64(3) + getRespCustom := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{ + GetCustomDomainCustomCertificate: &cdn.GetCustomDomainCustomCertificate{ + Type: &customType, + Version: &customVersion, + }, + }) + + managedType := "managed" + getRespManaged := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{ + GetCustomDomainManagedCertificate: &cdn.GetCustomDomainManagedCertificate{ + Type: &managedType, + }, + }) + + // Helper to create API response fixtures + customDomainFixture := func(mods ...func(*cdn.GetCustomDomainResponse)) *cdn.GetCustomDomainResponse { + distribution := &cdn.CustomDomain{ + Errors: &[]cdn.StatusError{}, + Name: cdn.PtrString("https://testdomain.com"), + Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(), + } + customDomainResponse := &cdn.GetCustomDomainResponse{ + CustomDomain: distribution, + Certificate: getRespCustom, + } + + for _, mod := range mods { + mod(customDomainResponse) + } + return customDomainResponse + } + + // Test cases + tests := map[string]struct { + Input *cdn.GetCustomDomainResponse + Expected *customDomainDataSourceModel + IsValid bool + }{ + "happy_path_custom_cert": { + Expected: expectedModel(func(m *customDomainDataSourceModel) { + m.Certificate = certificateObj + }), + Input: customDomainFixture(), + IsValid: true, + }, + "happy_path_managed_cert": { + Expected: expectedModel(func(m *customDomainDataSourceModel) { + m.Certificate = types.ObjectNull(certificateDataSourceTypes) + }), + Input: customDomainFixture(func(gcdr *cdn.GetCustomDomainResponse) { + gcdr.Certificate = getRespManaged + }), + IsValid: true, + }, + "happy_path_status_error": { + Expected: expectedModel(func(m *customDomainDataSourceModel) { + m.Status = types.StringValue("ERROR") + m.Certificate = certificateObj + }), + Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) { + d.CustomDomain.Status = cdn.DOMAINSTATUS_ERROR.Ptr() + }), + IsValid: true, + }, + "sad_path_response_nil": { + Expected: expectedModel(), + Input: nil, + IsValid: false, + }, + "sad_path_name_missing": { + Expected: expectedModel(), + Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) { + d.CustomDomain.Name = nil + }), + IsValid: false, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + model := &customDomainDataSourceModel{} + err := mapCustomDomainDataSourceFields(tc.Input, model, "test-project-id", "test-distribution-id") + + if err != nil && tc.IsValid { + t.Fatalf("Error mapping fields: %v", err) + } + if err == nil && !tc.IsValid { + t.Fatalf("Should have failed") + } + if tc.IsValid { + diff := cmp.Diff(tc.Expected, model) + if diff != "" { + t.Fatalf("Mapped model not as expected (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/cdn/customdomain/resource.go b/stackit/internal/services/cdn/customdomain/resource.go index 5e7bc94d..f974348e 100644 --- a/stackit/internal/services/cdn/customdomain/resource.go +++ b/stackit/internal/services/cdn/customdomain/resource.go @@ -2,6 +2,7 @@ package cdn import ( "context" + "encoding/base64" "errors" "fmt" "net/http" @@ -22,6 +23,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/cdn" @@ -37,6 +39,18 @@ var ( _ resource.ResourceWithConfigure = &customDomainResource{} _ resource.ResourceWithImportState = &customDomainResource{} ) +var certificateSchemaDescriptions = map[string]string{ + "main": "The TLS certificate for the custom domain. If omitted, a managed certificate will be used. If the block is specified, a custom certificate is used.", + "certificate": "The PEM-encoded TLS certificate. Required for custom certificates.", + "private_key": "The PEM-encoded private key for the certificate. Required for custom certificates. The certificate will be updated if this field is changed.", + "version": "A version identifier for the certificate. Required for custom certificates. The certificate will be updated if this field is changed.", +} + +var certificateTypes = map[string]attr.Type{ + "version": types.Int64Type, + "certificate": types.StringType, + "private_key": types.StringType, +} var customDomainSchemaDescriptions = map[string]string{ "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".", @@ -46,6 +60,12 @@ var customDomainSchemaDescriptions = map[string]string{ "errors": "List of distribution errors", } +type CertificateModel struct { + Certificate types.String `tfsdk:"certificate"` + PrivateKey types.String `tfsdk:"private_key"` + Version types.Int64 `tfsdk:"version"` +} + type CustomDomainModel struct { ID types.String `tfsdk:"id"` // Required by Terraform DistributionId types.String `tfsdk:"distribution_id"` // DistributionID associated with the cdn distribution @@ -53,6 +73,7 @@ type CustomDomainModel struct { Name types.String `tfsdk:"name"` // The custom domain Status types.String `tfsdk:"status"` // The status of the cdn distribution Errors types.List `tfsdk:"errors"` // Any errors that the distribution has + Certificate types.Object `tfsdk:"certificate"` // the certificate of the custom domain } type customDomainResource struct { @@ -63,6 +84,11 @@ func NewCustomDomainResource() resource.Resource { return &customDomainResource{} } +type Certificate struct { + Type string + Version *int64 +} + func (r *customDomainResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { @@ -120,6 +146,26 @@ func (r *customDomainResource) Schema(_ context.Context, _ resource.SchemaReques stringplanmodifier.RequiresReplace(), }, }, + "certificate": schema.SingleNestedAttribute{ + Description: certificateSchemaDescriptions["main"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "certificate": schema.StringAttribute{ + Description: certificateSchemaDescriptions["certificate"], + Optional: true, + Sensitive: true, + }, + "private_key": schema.StringAttribute{ + Description: certificateSchemaDescriptions["private_key"], + Optional: true, + Sensitive: true, + }, + "version": schema.Int64Attribute{ + Description: certificateSchemaDescriptions["version"], + Computed: true, + }, + }, + }, "status": schema.StringAttribute{ Computed: true, Description: customDomainSchemaDescriptions["status"], @@ -146,21 +192,33 @@ func (r *customDomainResource) Create(ctx context.Context, req resource.CreateRe ctx = tflog.SetField(ctx, "distribution_id", distributionId) name := model.Name.ValueString() ctx = tflog.SetField(ctx, "name", name) + certificate, err := toCertificatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Creating API payload: %v", err)) + return + } - payload := cdn.PutCustomDomainPayload{IntentId: cdn.PtrString(uuid.NewString())} - - _, err := r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute() + payload := cdn.PutCustomDomainPayload{ + IntentId: cdn.PtrString(uuid.NewString()), + Certificate: certificate, + } + _, err = r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Calling API: %v", err)) return } - waitResp, err := wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx) + _, err = wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Waiting for create: %v", err)) return } - err = mapCustomDomainFields(waitResp, &model) + respCustomDomain, err := r.client.GetCustomDomainExecute(ctx, projectId, distributionId, name) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Calling API: %v", err)) + return + } + err = mapCustomDomainResourceFields(respCustomDomain, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Processing API payload: %v", err)) return @@ -190,6 +248,7 @@ func (r *customDomainResource) Read(ctx context.Context, req resource.ReadReques ctx = tflog.SetField(ctx, "name", name) customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).Execute() + if err != nil { var oapiErr *oapierror.GenericOpenAPIError // n.b. err is caught here if of type *oapierror.GenericOpenAPIError, which the stackit SDK client returns @@ -202,7 +261,7 @@ func (r *customDomainResource) Read(ctx context.Context, req resource.ReadReques core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", fmt.Sprintf("Calling API: %v", err)) return } - err = mapCustomDomainFields(customDomainResp.CustomDomain, &model) + err = mapCustomDomainResourceFields(customDomainResp, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", fmt.Sprintf("Processing API payload: %v", err)) return @@ -216,9 +275,59 @@ func (r *customDomainResource) Read(ctx context.Context, req resource.ReadReques tflog.Info(ctx, "CDN custom domain read") } -func (r *customDomainResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - // Update shouldn't be called; custom domains have only computed fields and fields that require replacement when changed - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain", "Custom domain cannot be updated") +func (r *customDomainResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model CustomDomainModel + 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) + distributionId := model.DistributionId.ValueString() + ctx = tflog.SetField(ctx, "distribution_id", distributionId) + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + certificate, err := toCertificatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + payload := cdn.PutCustomDomainPayload{ + IntentId: cdn.PtrString(uuid.NewString()), + Certificate: certificate, + } + _, err = r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Waiting for update: %v", err)) + return + } + + respCustomDomain, err := r.client.GetCustomDomainExecute(ctx, projectId, distributionId, name) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Calling API to read final state: %v", err)) + return + } + err = mapCustomDomainResourceFields(respCustomDomain, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "CDN custom domain certificate updated") } func (r *customDomainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform @@ -260,30 +369,138 @@ func (r *customDomainResource) ImportState(ctx context.Context, req resource.Imp tflog.Info(ctx, "CDN custom domain state imported") } -func mapCustomDomainFields(customDomain *cdn.CustomDomain, model *CustomDomainModel) error { - if customDomain == nil { +func normalizeCertificate(certInput cdn.GetCustomDomainResponseGetCertificateAttributeType) (Certificate, error) { + var customCert *cdn.GetCustomDomainCustomCertificate + var managedCert *cdn.GetCustomDomainManagedCertificate + + if certInput == nil { + return Certificate{}, errors.New("input of type GetCustomDomainResponseCertificate is nil") + } + customCert = certInput.GetCustomDomainCustomCertificate + managedCert = certInput.GetCustomDomainManagedCertificate + + // Now we process the extracted certificates + if customCert != nil && customCert.Type != nil && customCert.Version != nil { + return Certificate{ + Type: *customCert.Type, + Version: customCert.Version, // Converts from *int64 to int + }, nil + } + + if managedCert != nil && managedCert.Type != nil { + // The version will be the zero value for int (0), as requested + return Certificate{ + Type: *managedCert.Type, + }, nil + } + + return Certificate{}, errors.New("certificate structure is empty, neither custom nor managed is set") +} + +// toCertificatePayload constructs the certificate part of the payload for the API request. +// It defaults to a managed certificate if the certificate block is omitted, otherwise it creates a custom certificate. +func toCertificatePayload(ctx context.Context, model *CustomDomainModel) (*cdn.PutCustomDomainPayloadCertificate, error) { + // If the certificate block is not specified, default to a managed certificate. + if model.Certificate.IsNull() { + managedCert := cdn.NewPutCustomDomainManagedCertificate("managed") + certPayload := cdn.PutCustomDomainManagedCertificateAsPutCustomDomainPayloadCertificate(managedCert) + return &certPayload, nil + } + + var certModel CertificateModel + // Unpack the Terraform object into the temporary struct. + respDiags := model.Certificate.As(ctx, &certModel, basetypes.ObjectAsOptions{}) + if respDiags.HasError() { + return nil, fmt.Errorf("invalid certificate or private key: %w", core.DiagsToError(respDiags)) + } + + if utils.IsUndefined(certModel.Certificate) || utils.IsUndefined(certModel.PrivateKey) { + return nil, fmt.Errorf(`"certificate" and "private_key" must be set`) + } + + certStr := base64.StdEncoding.EncodeToString([]byte(certModel.Certificate.ValueString())) + keyStr := base64.StdEncoding.EncodeToString([]byte(certModel.PrivateKey.ValueString())) + + if certStr == "" || keyStr == "" { + return nil, errors.New("invalid certificate or private key. Please check if the string of the public certificate and private key in PEM format") + } + + customCert := cdn.NewPutCustomDomainCustomCertificate( + certStr, + keyStr, + "custom", + ) + certPayload := cdn.PutCustomDomainCustomCertificateAsPutCustomDomainPayloadCertificate(customCert) + + return &certPayload, nil +} + +func mapCustomDomainResourceFields(customDomainResponse *cdn.GetCustomDomainResponse, model *CustomDomainModel) error { + if customDomainResponse == nil { return fmt.Errorf("response input is nil") } if model == nil { return fmt.Errorf("model input is nil") } - if customDomain.Name == nil { - return fmt.Errorf("Name is missing in response") + if customDomainResponse.CustomDomain == nil { + return fmt.Errorf("CustomDomain is missing in response") + } + if customDomainResponse.CustomDomain.Name == nil { + return fmt.Errorf("name is missing in response") } - if customDomain.Status == nil { - return fmt.Errorf("Status missing in response") + if customDomainResponse.CustomDomain.Status == nil { + return fmt.Errorf("status missing in response") + } + normalizedCert, err := normalizeCertificate(customDomainResponse.Certificate) + if err != nil { + return fmt.Errorf("Certificate error in normalizer: %w", err) } - model.ID = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.DistributionId.ValueString(), *customDomain.Name) - model.Status = types.StringValue(string(*customDomain.Status)) + // If the certificate is managed, the certificate block in the state should be null. + if normalizedCert.Type == "managed" { + model.Certificate = types.ObjectNull(certificateTypes) + } else { + // If the certificate is custom, we need to preserve the user-configured + // certificate and private key from the plan/state, and only update the computed version. + certAttributes := map[string]attr.Value{ + "certificate": types.StringNull(), // Default to null + "private_key": types.StringNull(), // Default to null + "version": types.Int64Null(), + } + + // Get existing values from the model's certificate object if it exists + if !model.Certificate.IsNull() { + existingAttrs := model.Certificate.Attributes() + if val, ok := existingAttrs["certificate"]; ok { + certAttributes["certificate"] = val + } + if val, ok := existingAttrs["private_key"]; ok { + certAttributes["private_key"] = val + } + } + + // Set the computed version from the API response + if normalizedCert.Version != nil { + certAttributes["version"] = types.Int64Value(*normalizedCert.Version) + } + + certificateObj, diags := types.ObjectValue(certificateTypes, certAttributes) + if diags.HasError() { + return fmt.Errorf("failed to map certificate: %w", core.DiagsToError(diags)) + } + model.Certificate = certificateObj + } + + model.ID = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.DistributionId.ValueString(), *customDomainResponse.CustomDomain.Name) + model.Status = types.StringValue(string(*customDomainResponse.CustomDomain.Status)) customDomainErrors := []attr.Value{} - if customDomain.Errors != nil { - for _, e := range *customDomain.Errors { + if customDomainResponse.CustomDomain.Errors != nil { + for _, e := range *customDomainResponse.CustomDomain.Errors { if e.En == nil { - return fmt.Errorf("Error description missing") + return fmt.Errorf("error description missing") } customDomainErrors = append(customDomainErrors, types.StringValue(*e.En)) } diff --git a/stackit/internal/services/cdn/customdomain/resource_test.go b/stackit/internal/services/cdn/customdomain/resource_test.go index 1d72ea11..28aff294 100644 --- a/stackit/internal/services/cdn/customdomain/resource_test.go +++ b/stackit/internal/services/cdn/customdomain/resource_test.go @@ -1,16 +1,47 @@ package cdn import ( + "context" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/services/cdn" ) func TestMapFields(t *testing.T) { + // Redefine certificateTypes locally for testing, matching the updated schema + certificateTypes := map[string]attr.Type{ + "version": types.Int64Type, + "certificate": types.StringType, + "private_key": types.StringType, + } + + const dummyCert = "dummy-cert-pem" + const dummyKey = "dummy-key-pem" + emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) + + // Expected object when a custom certificate is returned + certAttributes := map[string]attr.Value{ + "version": types.Int64Value(3), + "certificate": types.StringValue(dummyCert), + "private_key": types.StringValue(dummyKey), + } + certificateObj, _ := types.ObjectValue(certificateTypes, certAttributes) + expectedModel := func(mods ...func(*CustomDomainModel)) *CustomDomainModel { model := &CustomDomainModel{ ID: types.StringValue("test-project-id,test-distribution-id,https://testdomain.com"), @@ -18,61 +49,117 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("test-project-id"), Status: types.StringValue("ACTIVE"), Errors: emtpyErrorsList, + Certificate: types.ObjectUnknown(certificateTypes), } for _, mod := range mods { mod(model) } return model } - customDomainFixture := func(mods ...func(*cdn.CustomDomain)) *cdn.CustomDomain { + + customType := "custom" + customVersion := int64(3) + getRespCustom := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{ + GetCustomDomainCustomCertificate: &cdn.GetCustomDomainCustomCertificate{ + Type: &customType, + Version: &customVersion, + }, + }) + + managedType := "managed" + getRespManaged := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{ + GetCustomDomainManagedCertificate: &cdn.GetCustomDomainManagedCertificate{ + Type: &managedType, + }, + }) + + customDomainFixture := func(mods ...func(*cdn.GetCustomDomainResponse)) *cdn.GetCustomDomainResponse { distribution := &cdn.CustomDomain{ Errors: &[]cdn.StatusError{}, Name: cdn.PtrString("https://testdomain.com"), Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(), } - for _, mod := range mods { - mod(distribution) + customDomainResponse := &cdn.GetCustomDomainResponse{ + CustomDomain: distribution, + Certificate: getRespCustom, } - return distribution + + for _, mod := range mods { + mod(customDomainResponse) + } + return customDomainResponse } + tests := map[string]struct { - Input *cdn.CustomDomain - Expected *CustomDomainModel - IsValid bool + Input *cdn.GetCustomDomainResponse + Certificate interface{} + Expected *CustomDomainModel + InitialModel *CustomDomainModel + IsValid bool + SkipInitialNil bool }{ - "happy_path": { - Expected: expectedModel(), - Input: customDomainFixture(), - IsValid: true, + "happy_path_custom_cert": { + Expected: expectedModel(func(m *CustomDomainModel) { + m.Certificate = certificateObj + }), + Input: customDomainFixture(), + IsValid: true, + InitialModel: expectedModel(func(m *CustomDomainModel) { + m.Certificate = basetypes.NewObjectValueMust(certificateTypes, map[string]attr.Value{ + "certificate": types.StringValue(dummyCert), + "private_key": types.StringValue(dummyKey), + "version": types.Int64Null(), + }) + }), + }, + "happy_path_managed_cert": { + Expected: expectedModel(func(m *CustomDomainModel) { + m.Certificate = types.ObjectNull(certificateTypes) + }), + Input: customDomainFixture(func(gcdr *cdn.GetCustomDomainResponse) { + gcdr.Certificate = getRespManaged + }), + IsValid: true, + InitialModel: expectedModel(func(m *CustomDomainModel) { m.Certificate = types.ObjectNull(certificateTypes) }), }, "happy_path_status_error": { Expected: expectedModel(func(m *CustomDomainModel) { m.Status = types.StringValue("ERROR") + m.Certificate = certificateObj }), - Input: customDomainFixture(func(d *cdn.CustomDomain) { - d.Status = cdn.DOMAINSTATUS_ERROR.Ptr() + Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) { + d.CustomDomain.Status = cdn.DOMAINSTATUS_ERROR.Ptr() }), IsValid: true, + InitialModel: expectedModel(func(m *CustomDomainModel) { + m.Certificate = basetypes.NewObjectValueMust(certificateTypes, map[string]attr.Value{ + "certificate": types.StringValue(dummyCert), + "private_key": types.StringValue(dummyKey), + "version": types.Int64Null(), + }) + }), }, "sad_path_custom_domain_nil": { - Expected: expectedModel(), - Input: nil, - IsValid: false, + Expected: expectedModel(), + Input: nil, + IsValid: false, + InitialModel: &CustomDomainModel{}, }, "sad_path_name_missing": { Expected: expectedModel(), - Input: customDomainFixture(func(d *cdn.CustomDomain) { - d.Name = nil + Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) { + d.CustomDomain.Name = nil }), - IsValid: false, + IsValid: false, + InitialModel: &CustomDomainModel{}, }, } for tn, tc := range tests { t.Run(tn, func(t *testing.T) { - model := &CustomDomainModel{} + model := tc.InitialModel model.DistributionId = tc.Expected.DistributionId model.ProjectId = tc.Expected.ProjectId - err := mapCustomDomainFields(tc.Input, model) + err := mapCustomDomainResourceFields(tc.Input, model) if err != nil && tc.IsValid { t.Fatalf("Error mapping fields: %v", err) } @@ -80,11 +167,142 @@ func TestMapFields(t *testing.T) { t.Fatalf("Should have failed") } if tc.IsValid { - diff := cmp.Diff(model, tc.Expected) + diff := cmp.Diff(tc.Expected, model) if diff != "" { - t.Fatalf("Create Payload not as expected: %s", diff) + t.Fatalf("Mapped model not as expected (-want +got):\n%s", diff) } } }) } } + +func makeCertAndKey(t *testing.T, organization string) (cert, key []byte) { + privateKey, err := rsa.GenerateKey(cryptoRand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate key: %s", err.Error()) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Issuer: pkix.Name{CommonName: organization}, + Subject: pkix.Name{ + Organization: []string{organization}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + cert, err = x509.CreateCertificate( + cryptoRand.Reader, + &template, + &template, + &privateKey.PublicKey, + privateKey, + ) + if err != nil { + t.Fatalf("failed to generate cert: %s", err.Error()) + } + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + }), pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) +} +func TestToCertificatePayload(t *testing.T) { + organization := fmt.Sprintf("organization-%s", uuid.NewString()) + cert, key := makeCertAndKey(t, organization) + certPEM := string(cert) + keyPEM := string(key) + certBase64 := base64.StdEncoding.EncodeToString(cert) + keyBase64 := base64.StdEncoding.EncodeToString(key) + + tests := map[string]struct { + model *CustomDomainModel + expectedPayload *cdn.PutCustomDomainPayloadCertificate + expectErr bool + expectedErrMsg string + }{ + "success_managed_when_certificate_block_is_nil": { + model: &CustomDomainModel{ + Certificate: types.ObjectNull(certificateTypes), + }, + expectedPayload: &cdn.PutCustomDomainPayloadCertificate{ + PutCustomDomainManagedCertificate: cdn.NewPutCustomDomainManagedCertificate("managed"), + }, + expectErr: false, + }, + "success_custom_certificate": { + model: &CustomDomainModel{ + Certificate: basetypes.NewObjectValueMust( + certificateTypes, + map[string]attr.Value{ + "version": types.Int64Null(), + "certificate": types.StringValue(certPEM), + "private_key": types.StringValue(keyPEM), + }, + ), + }, + expectedPayload: &cdn.PutCustomDomainPayloadCertificate{ + PutCustomDomainCustomCertificate: cdn.NewPutCustomDomainCustomCertificate(certBase64, keyBase64, "custom"), + }, + expectErr: false, + }, + "fail_custom_missing_cert_value": { + model: &CustomDomainModel{ + Certificate: basetypes.NewObjectValueMust( + certificateTypes, + map[string]attr.Value{ + "version": types.Int64Null(), + "certificate": types.StringValue(""), // Empty certificate + "private_key": types.StringValue(keyPEM), + }, + ), + }, + expectErr: true, + expectedErrMsg: "invalid certificate or private key. Please check if the string of the public certificate and private key in PEM format", + }, + + "success_managed_when_certificate_attributes_are_nil": { + model: &CustomDomainModel{ + Certificate: basetypes.NewObjectValueMust( + certificateTypes, + map[string]attr.Value{ + "version": types.Int64Null(), + "certificate": types.StringNull(), + "private_key": types.StringNull(), + }, + ), + }, + expectErr: true, + expectedErrMsg: `"certificate" and "private_key" must be set`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + payload, err := toCertificatePayload(context.Background(), tt.model) + if tt.expectErr { + if err == nil { + t.Fatalf("expected err, but got none") + } + if err.Error() != tt.expectedErrMsg { + t.Fatalf("expected err '%s', got '%s'", tt.expectedErrMsg, err.Error()) + } + return // Test ends here for failing cases + } + + if err != nil { + t.Fatalf("did not expect err, but got: %s", err.Error()) + } + + if diff := cmp.Diff(tt.expectedPayload, payload); diff != "" { + t.Errorf("payload mismatch (-want +got):\n%s", diff) + } + }) + } +}