diff --git a/examples/resources/cloudflare_certificate_authorities_hostname_associations/import.sh b/examples/resources/cloudflare_certificate_authorities_hostname_associations/import.sh new file mode 100644 index 0000000000..ea5db053fb --- /dev/null +++ b/examples/resources/cloudflare_certificate_authorities_hostname_associations/import.sh @@ -0,0 +1,5 @@ +# import hostname associations for the active Cloudflare Managed CA +$ terraform import cloudflare_certificate_authorities_hostname_associations.example + +# import hostname associations for the specified mTLS certificate +$ terraform import cloudflare_certificate_authorities_hostname_associations.example / \ No newline at end of file diff --git a/examples/resources/cloudflare_certificate_authorities_hostname_associations/resource.tf b/examples/resources/cloudflare_certificate_authorities_hostname_associations/resource.tf new file mode 100644 index 0000000000..42f808e1d0 --- /dev/null +++ b/examples/resources/cloudflare_certificate_authorities_hostname_associations/resource.tf @@ -0,0 +1,12 @@ +# Hostname associations for the active Cloudflare Managed CA. +resource "cloudflare_certificate_authorities_hostname_associations" "example_1" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + hostnames = ["example.com"] +} + +# Hostname associations for a specific mTLS certificate. +resource "cloudflare_certificate_authorities_hostname_associations" "example_2" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + mtls_certificate_id = "1fc1e34f39e74dd591366239dc47c5a4" + hostnames = ["example.com"] +} diff --git a/internal/framework/service/certificate_authorities_hostname_associations/model.go b/internal/framework/service/certificate_authorities_hostname_associations/model.go new file mode 100644 index 0000000000..bc5d807c46 --- /dev/null +++ b/internal/framework/service/certificate_authorities_hostname_associations/model.go @@ -0,0 +1,11 @@ +package certificate_authorities_hostname_associations + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type HostnameAssociation = types.String + +type CertificateAuthoritiesHostnameAssociationsModel struct { + ZoneID types.String `tfsdk:"zone_id"` + MTLSCertificateID types.String `tfsdk:"mtls_certificate_id"` + Hostnames []HostnameAssociation `tfsdk:"hostnames"` +} diff --git a/internal/framework/service/certificate_authorities_hostname_associations/resource.go b/internal/framework/service/certificate_authorities_hostname_associations/resource.go new file mode 100644 index 0000000000..cdaa81110d --- /dev/null +++ b/internal/framework/service/certificate_authorities_hostname_associations/resource.go @@ -0,0 +1,191 @@ +package certificate_authorities_hostname_associations + +import ( + "context" + "fmt" + "strings" + + cfv1 "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/muxclient" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &CertificateAuthoritiesHostnameAssociationsResource{} +var _ resource.ResourceWithImportState = &CertificateAuthoritiesHostnameAssociationsResource{} + +func NewResource() resource.Resource { + return &CertificateAuthoritiesHostnameAssociationsResource{} +} + +type CertificateAuthoritiesHostnameAssociationsResource struct { + client *muxclient.Client +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_certificate_authorities_hostname_associations" +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*muxclient.Client) + + if !ok { + resp.Diagnostics.AddError( + "unexpected resource configure type", + fmt.Sprintf("Expected *muxclient.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *CertificateAuthoritiesHostnameAssociationsModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + updatedHostnames, err := r.update(ctx, data) + if err != nil { + resp.Diagnostics.AddError("error updating Certificate Authorities Hostname Associations", err.Error()) + return + } + + data = buildCertificateAuthoritiesHostnameAssociationsModel(updatedHostnames, data.MTLSCertificateID, data.ZoneID) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *CertificateAuthoritiesHostnameAssociationsModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + identifier := cfv1.ZoneIdentifier(data.ZoneID.ValueString()) + params := cfv1.ListCertificateAuthoritiesHostnameAssociationsParams{ + MTLSCertificateID: data.MTLSCertificateID.ValueString(), + } + + hostnames, err := r.client.V1.ListCertificateAuthoritiesHostnameAssociations(ctx, identifier, params) + if err != nil { + resp.Diagnostics.AddError("error reading Access Mutual TLS Hostname Settings", err.Error()) + return + } + data = buildCertificateAuthoritiesHostnameAssociationsModel(hostnames, data.MTLSCertificateID, data.ZoneID) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// Helper function used by both Update, Create and Delete +func (r *CertificateAuthoritiesHostnameAssociationsResource) update(ctx context.Context, data *CertificateAuthoritiesHostnameAssociationsModel) ([]cfv1.HostnameAssociation, error) { + updatedHostnames := []cfv1.HostnameAssociation{} + identifier := cfv1.ZoneIdentifier(data.ZoneID.ValueString()) + + hostnames := data.Hostnames + for _, hostname := range hostnames { + updatedHostnames = append(updatedHostnames, hostname.ValueString()) + } + + updatedCertificateAuthoritiesHostnameAssociations := cfv1.UpdateCertificateAuthoritiesHostnameAssociationsParams{ + MTLSCertificateID: data.MTLSCertificateID.ValueString(), + Hostnames: updatedHostnames, + } + + tflog.Debug(ctx, fmt.Sprintf("Updating Cloudflare Certificate Authorities Hostname Associations from struct: %+v", updatedCertificateAuthoritiesHostnameAssociations)) + + resultUpdatedHostnames, err := r.client.V1.UpdateCertificateAuthoritiesHostnameAssociations(ctx, identifier, updatedCertificateAuthoritiesHostnameAssociations) + if err != nil { + return nil, fmt.Errorf("error updating Certificate Authorities Hostname Associations for %s %q %s: %w", identifier.Level, identifier.Identifier, data.MTLSCertificateID, err) + } + return resultUpdatedHostnames, nil +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data *CertificateAuthoritiesHostnameAssociationsModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + updatedHostnames, err := r.update(ctx, data) + if err != nil { + resp.Diagnostics.AddError("error updating Certificate Authorities Hostname Associations", err.Error()) + return + } + + data = buildCertificateAuthoritiesHostnameAssociationsModel(updatedHostnames, data.MTLSCertificateID, data.ZoneID) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *CertificateAuthoritiesHostnameAssociationsModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + identifier := cfv1.ZoneIdentifier(data.ZoneID.ValueString()) + + // To delete the associations we issue and update an empty array of hostnames + deletedCertificateAuthoritiesHostnameAssociations := cfv1.UpdateCertificateAuthoritiesHostnameAssociationsParams{ + MTLSCertificateID: data.MTLSCertificateID.ValueString(), + Hostnames: []cfv1.HostnameAssociation{}, + } + + _, err := r.client.V1.UpdateCertificateAuthoritiesHostnameAssociations(ctx, identifier, deletedCertificateAuthoritiesHostnameAssociations) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error removing Certificate Authorities Hostname Associations for %s %q", identifier.Level, identifier.Identifier), err.Error()) + return + } +} + +func (r *CertificateAuthoritiesHostnameAssociationsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + attributes := strings.Split(req.ID, "/") + + invalidIDMessage := "invalid ID (\"%s\") specified, should be in format \"\" or \"/\"" + if len(attributes) != 1 || len(attributes) != 2 { + resp.Diagnostics.AddError("error importing Certificate Authorities Hostname Associations", fmt.Sprintf(invalidIDMessage, req.ID)) + return + } + + zoneID := attributes[0] + mtlsCertificateID := "" + if len(attributes) == 2 { + mtlsCertificateID = attributes[1] + } + + tflog.Debug(ctx, fmt.Sprintf("Importing Certificate Authorities Hostname Associations: for %s %s", zoneID, mtlsCertificateID)) + + resp.Diagnostics.Append( + resp.State.SetAttribute(ctx, path.Root("zone_id"), zoneID)..., + ) + + resp.Diagnostics.Append( + resp.State.SetAttribute(ctx, path.Root("mtls_certificate_id"), mtlsCertificateID)..., + ) +} + +func buildCertificateAuthoritiesHostnameAssociationsModel(hostnames []cfv1.HostnameAssociation, mtlsCertificateID basetypes.StringValue, zoneID basetypes.StringValue) *CertificateAuthoritiesHostnameAssociationsModel { + model := &CertificateAuthoritiesHostnameAssociationsModel{ + ZoneID: zoneID, + MTLSCertificateID: mtlsCertificateID, + } + for _, hostname := range hostnames { + model.Hostnames = append(model.Hostnames, types.StringValue(hostname)) + } + return model +} diff --git a/internal/framework/service/certificate_authorities_hostname_associations/resource_test.go b/internal/framework/service/certificate_authorities_hostname_associations/resource_test.go new file mode 100644 index 0000000000..e935161acd --- /dev/null +++ b/internal/framework/service/certificate_authorities_hostname_associations/resource_test.go @@ -0,0 +1,90 @@ +package certificate_authorities_hostname_associations + +import ( + "context" + "fmt" + "os" + "testing" + + cfv1 "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/acctest" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/cloudflare/terraform-provider-cloudflare/internal/utils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudflareCertificateAuthoritiesHostnameAssociations_Create(t *testing.T) { + t.Parallel() + rnd := utils.GenerateRandomResourceName() + name := fmt.Sprintf("cloudflare_certificate_authorities_hostname_associations.%s", rnd) + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + zoneName := os.Getenv("CLOUDFLARE_DOMAIN") + hostname := rnd + "." + zoneName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.TestAccPreCheck(t) + acctest.TestAccPreCheck_Account(t) + }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckCloudflareCertificateAuthoritiesHostnameAssociationsDestroy, + Steps: []resource.TestStep{ + { + Config: testCertificateAuthoritiesHostnameAssociationsConfig(rnd, accountID, zoneID, hostname), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttrSet(name, "mtls_certificate_id"), + resource.TestCheckResourceAttr(name, "hostnames.0", hostname), + ), + }, + }, + }) +} + +func testAccCheckCloudflareCertificateAuthoritiesHostnameAssociationsDestroy(s *terraform.State) error { + client, err := acctest.SharedV1Client() + if err != nil { + return fmt.Errorf("Failed to create Cloudflare client: %w", err) + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_zero_trust_access_mtls_hostname_settings" { + continue + } + + if rs.Primary.Attributes[consts.ZoneIDSchemaKey] != "" { + settings, _ := client.GetAccessMutualTLSHostnameSettings(context.Background(), cfv1.ZoneIdentifier(rs.Primary.Attributes[consts.ZoneIDSchemaKey])) + if len(settings) != 0 { + return fmt.Errorf("AccessMutualTLSHostnameSettings still exists") + } + } + + if rs.Primary.Attributes[consts.AccountIDSchemaKey] != "" { + settings, _ := client.GetAccessMutualTLSHostnameSettings(context.Background(), cfv1.AccountIdentifier(rs.Primary.Attributes[consts.AccountIDSchemaKey])) + if len(settings) != 0 { + return fmt.Errorf("AccessMutualTLSHostnameSettings still exists") + } + } + } + + return nil +} + +func testCertificateAuthoritiesHostnameAssociationsConfig(rnd string, accountID string, zoneID string, hostname string) string { + return fmt.Sprintf(` +resource "cloudflare_mtls_certificate" "%[1]s" { + account_id = "%[2]s" + name = "" + certificates = "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----" + private_key = "" + ca = true +} +resource "cloudflare_certificate_authorities_hostname_associations" "%[1]s" { + zone_id = "%[3]s" + mtls_certificate_id = cloudflare_mtls_certificate.%[1]s.id + hostnames = ["%[4]s"] +} +`, rnd, accountID, zoneID, hostname) +} diff --git a/internal/framework/service/certificate_authorities_hostname_associations/schema.go b/internal/framework/service/certificate_authorities_hostname_associations/schema.go new file mode 100644 index 0000000000..4d5290f473 --- /dev/null +++ b/internal/framework/service/certificate_authorities_hostname_associations/schema.go @@ -0,0 +1,31 @@ +package certificate_authorities_hostname_associations + +import ( + "context" + + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *CertificateAuthoritiesHostnameAssociationsResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Provides a Cloudflare Certificate Authorities Hostname Associations resource.", + Attributes: map[string]schema.Attribute{ + consts.ZoneIDSchemaKey: schema.StringAttribute{ + Description: consts.ZoneIDSchemaDescription, + Required: true, + }, + "mtls_certificate_id": schema.StringAttribute{ + Description: "TODO", + Optional: true, + }, + "hostnames": schema.ListAttribute{ + Description: "TODO", + Required: true, + ElementType: types.StringType, + }, + }, + } +}