diff --git a/cloudflare/provider.go b/cloudflare/provider.go index a646c8ca28..0d7711a0ad 100644 --- a/cloudflare/provider.go +++ b/cloudflare/provider.go @@ -113,6 +113,7 @@ func Provider() terraform.ResourceProvider { "cloudflare_authenticated_origin_pulls": resourceCloudflareAuthenticatedOriginPulls(), "cloudflare_authenticated_origin_pulls_certificate": resourceCloudflareAuthenticatedOriginPullsCertificate(), "cloudflare_byo_ip_prefix": resourceCloudflareBYOIPPrefix(), + "cloudflare_certificate_pack": resourceCloudflareCertificatePack(), "cloudflare_custom_hostname": resourceCloudflareCustomHostname(), "cloudflare_custom_hostname_fallback_origin": resourceCloudflareCustomHostnameFallbackOrigin(), "cloudflare_custom_pages": resourceCloudflareCustomPages(), diff --git a/cloudflare/resource_cloudflare_certificate_pack.go b/cloudflare/resource_cloudflare_certificate_pack.go new file mode 100644 index 0000000000..3ff8ddc312 --- /dev/null +++ b/cloudflare/resource_cloudflare_certificate_pack.go @@ -0,0 +1,161 @@ +package cloudflare + +import ( + "fmt" + "log" + "strings" + + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/pkg/errors" +) + +func resourceCloudflareCertificatePack() *schema.Resource { + return &schema.Resource{ + // Intentionally no Update method as certificates require replacement for + // any changes made. + Create: resourceCloudflareCertificatePackCreate, + Read: resourceCloudflareCertificatePackRead, + Delete: resourceCloudflareCertificatePackDelete, + Importer: &schema.ResourceImporter{ + State: resourceCloudflareCertificatePackImport, + }, + + Schema: map[string]*schema.Schema{ + "zone_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"custom", "dedicated_custom", "advanced"}, false), + }, + "hosts": { + Type: schema.TypeList, + Required: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "validation_method": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"txt", "http", "email"}, false), + }, + "validity_days": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: validation.IntInSlice([]int{14, 30, 90, 365}), + }, + "certificate_authority": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"digicert", "lets_encrypt"}, false), + }, + "cloudflare_branding": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + }, + } +} + +func resourceCloudflareCertificatePackCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + zoneID := d.Get("zone_id").(string) + certificatePackType := d.Get("type").(string) + certificateHostnames := expandInterfaceToStringList(d.Get("hosts").([]interface{})) + certificatePackID := "" + + if certificatePackType == "advanced" { + validationMethod := d.Get("validation_method").(string) + validityDays := d.Get("validity_days").(int) + ca := d.Get("certificate_authority").(string) + cloudflareBranding := d.Get("cloudflare_branding").(bool) + + cert := cloudflare.CertificatePackAdvancedCertificate{ + Type: "advanced", + Hosts: certificateHostnames, + ValidationMethod: validationMethod, + ValidityDays: validityDays, + CertificateAuthority: ca, + CloudflareBranding: cloudflareBranding, + } + certPackResponse, err := client.CreateAdvancedCertificatePack(zoneID, cert) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to create certificate pack: %s", err)) + } + certificatePackID = certPackResponse.ID + } else { + cert := cloudflare.CertificatePackRequest{ + Type: certificatePackType, + Hosts: certificateHostnames, + } + certPackResponse, err := client.CreateCertificatePack(zoneID, cert) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to create certificate pack: %s", err)) + } + certificatePackID = certPackResponse.ID + } + + d.SetId(certificatePackID) + + return resourceCloudflareCertificatePackRead(d, meta) +} + +func resourceCloudflareCertificatePackRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + zoneID := d.Get("zone_id").(string) + + certificatePack, err := client.CertificatePack(zoneID, d.Id()) + if err != nil { + return errors.Wrap(err, "failed to fetch certificate pack") + } + + d.Set("type", certificatePack.Type) + d.Set("hosts", flattenStringList(certificatePack.Hosts)) + + return nil +} + +func resourceCloudflareCertificatePackDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + zoneID := d.Get("zone_id").(string) + + err := client.DeleteCertificatePack(zoneID, d.Id()) + if err != nil { + return errors.Wrap(err, "failed to delete certificate pack") + } + + resourceCloudflareCertificatePackRead(d, meta) + + return nil +} + +func resourceCloudflareCertificatePackImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + attributes := strings.SplitN(d.Id(), "/", 2) + + if len(attributes) != 2 { + return nil, fmt.Errorf("invalid id (\"%s\") specified, should be in format \"zoneID/certificatePackID\"", d.Id()) + } + + zoneID, certificatePackID := attributes[0], attributes[1] + + log.Printf("[DEBUG] Importing Cloudflare Certificate Pack: id %s for zone %s", certificatePackID, zoneID) + + d.Set("zone_id", zoneID) + d.SetId(certificatePackID) + + resourceCloudflareCertificatePackRead(d, meta) + + return []*schema.ResourceData{d}, nil +} diff --git a/cloudflare/resource_cloudflare_certificate_pack_test.go b/cloudflare/resource_cloudflare_certificate_pack_test.go new file mode 100644 index 0000000000..9cb2ce949c --- /dev/null +++ b/cloudflare/resource_cloudflare_certificate_pack_test.go @@ -0,0 +1,130 @@ +package cloudflare + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccCertificatePackAdvancedDigicert(t *testing.T) { + rnd := generateRandomResourceName() + name := "cloudflare_certificate_pack." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + domain := os.Getenv("CLOUDFLARE_DOMAIN") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCertificatePackAdvancedDigicertConfig(zoneID, domain, "advanced", rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "zone_id", zoneID), + resource.TestCheckResourceAttr(name, "type", "advanced"), + resource.TestCheckResourceAttr(name, "hosts.0", fmt.Sprintf("%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "hosts.1", domain), + resource.TestCheckResourceAttr(name, "validation_method", "http"), + resource.TestCheckResourceAttr(name, "validity_days", "365"), + resource.TestCheckResourceAttr(name, "certificate_authority", "digicert"), + resource.TestCheckResourceAttr(name, "cloudflare_branding", "false"), + ), + }, + }, + }) +} + +func testAccCertificatePackAdvancedDigicertConfig(zoneID, domain, certType, rnd string) string { + return fmt.Sprintf(` +resource "cloudflare_certificate_pack" "%[3]s" { + zone_id = "%[1]s" + type = "%[4]s" + hosts = [ + "%[3]s.%[2]s", + "%[2]s" + ] + validation_method = "http" + validity_days = 365 + certificate_authority = "digicert" + cloudflare_branding = false +}`, zoneID, domain, rnd, certType) +} + +func TestAccCertificatePackAdvancedLetsEncrypt(t *testing.T) { + rnd := generateRandomResourceName() + name := "cloudflare_certificate_pack." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + domain := os.Getenv("CLOUDFLARE_DOMAIN") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCertificatePackAdvancedLetsEncryptConfig(zoneID, domain, "advanced", rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "zone_id", zoneID), + resource.TestCheckResourceAttr(name, "type", "advanced"), + resource.TestCheckResourceAttr(name, "hosts.0", fmt.Sprintf("*.%s", domain)), + resource.TestCheckResourceAttr(name, "hosts.1", domain), + resource.TestCheckResourceAttr(name, "validation_method", "txt"), + resource.TestCheckResourceAttr(name, "validity_days", "90"), + resource.TestCheckResourceAttr(name, "certificate_authority", "lets_encrypt"), + resource.TestCheckResourceAttr(name, "cloudflare_branding", "false"), + ), + }, + }, + }) +} + +func testAccCertificatePackAdvancedLetsEncryptConfig(zoneID, domain, certType, rnd string) string { + return fmt.Sprintf(` +resource "cloudflare_certificate_pack" "%[3]s" { + zone_id = "%[1]s" + type = "%[4]s" + hosts = [ + "*.%[2]s", + "%[2]s" + ] + validation_method = "txt" + validity_days = 90 + certificate_authority = "lets_encrypt" + cloudflare_branding = false +}`, zoneID, domain, rnd, certType) +} + +func TestAccCertificatePackDedicatedCustom(t *testing.T) { + rnd := generateRandomResourceName() + name := "cloudflare_certificate_pack." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + domain := os.Getenv("CLOUDFLARE_DOMAIN") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCertificatePackDedicatedCustomConfig(zoneID, domain, "dedicated_custom", rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "zone_id", zoneID), + resource.TestCheckResourceAttr(name, "type", "dedicated_custom"), + resource.TestCheckResourceAttr(name, "hosts.0", fmt.Sprintf("%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "hosts.1", domain), + ), + }, + }, + }) +} + +func testAccCertificatePackDedicatedCustomConfig(zoneID, domain, certType, rnd string) string { + return fmt.Sprintf(` +resource "cloudflare_certificate_pack" "%[3]s" { + zone_id = "%[1]s" + type = "%[4]s" + hosts = [ + "%[3]s.%[2]s", + "%[2]s" + ] +}`, zoneID, domain, rnd, certType) +} diff --git a/website/cloudflare.erb b/website/cloudflare.erb index 806eedb21e..77da4d859f 100644 --- a/website/cloudflare.erb +++ b/website/cloudflare.erb @@ -70,6 +70,9 @@ > cloudflare_byo_ip_prefix + > + cloudflare_certificate_pack + > cloudflare_custom_pages diff --git a/website/docs/r/certificate_pack.html.markdown b/website/docs/r/certificate_pack.html.markdown new file mode 100644 index 0000000000..a3a7caa38e --- /dev/null +++ b/website/docs/r/certificate_pack.html.markdown @@ -0,0 +1,85 @@ +--- +layout: "cloudflare" +page_title: "Cloudflare: cloudflare_certificate_pack" +sidebar_current: "docs-cloudflare-resource-certificate-pack" +description: |- + Provides a Cloudflare Certificate Pack resource. +--- + +# cloudflare_certificate_pack + +Provides a Cloudflare Certificate Pack resource that is used to provision +managed TLS certificates. + +~> **Important:** Certificate packs are not able to be updated in place and if +you require a zero downtime rotation, you need to use Terraform's meta-arguments +for [`lifecycle`](https://www.terraform.io/docs/configuration/resources.html#lifecycle-lifecycle-customizations) blocks. +`create_before_destroy` should be suffice for most scenarios (exceptions are +things like missing entitlements, high ranking domain). To completely +de-risk rotations, use you can create multiple resources using a 2-phase change +where you have both resources live at once and you remove the old one once +you've confirmed the certificate is available. + +## Example Usage + +```hcl +resource "cloudflare_certificate_pack" "dedicated_custom_example" { + zone_id = "1d5fdc9e88c8a8c4518b068cd94331fe" + type = "dedicated_custom" + hosts = ["example.com", "sub.example.com"] +} + +# Advanced certificate manager for DigiCert +resource "cloudflare_certificate_pack" "advanced_example_for_digicert" { + zone_id = "1d5fdc9e88c8a8c4518b068cd94331fe" + type = "advanced" + hosts = ["example.com", "sub.example.com"] + validation_method = "txt" + validity_days = 30 + certificate_authority = "digicert" + cloudflare_branding = false +} + +# Advanced certificate manager for Let's Encrypt +resource "cloudflare_certificate_pack" "advanced_example_for_lets_encrypt" { + zone_id = "1d5fdc9e88c8a8c4518b068cd94331fe" + type = "advanced" + hosts = ["example.com", "*.example.com"] + validation_method = "http" + validity_days = 90 + certificate_authority = "lets_encrypot" + cloudflare_branding = false +} +``` + +## Argument Reference + +The following arguments are supported: + +* `zone_id` - (Required) The DNS zone to which the certificate pack should be added. +* `type` - (Required) Certificate pack configuration type. + Allowed values: `"custom"`, `"dedicated_custom"`, `"advanced"`. +* `hosts` - (Required) List of hostnames to provision the certificate pack for. + Note: If using Let's Encrypt, you cannot use individual subdomains and only a + wildcard for subdomain is available. +* `validation_method` - (Optional based on `type`) Which validation method to + use in order to prove domain ownership. Allowed values: `"txt"`, `"http"`, `"email"`. +* `validity_days` - (Optional based on `type`) How long the certificate is valid + for. Note: If using Let's Encrypt, this value can only be 90 days. + Allowed values: 14, 30, 90, 365. +* `certificate_authority` - (Optional based on `type`) Which certificate + authority to issue the certificate pack. Allowed values: `"digicert"`, + `"lets_encrypt"`. +* `cloudflare_branding` - (Optional based on `type`) Whether or not to include + Cloudflare branding. This will add `sni.cloudflaressl.com` as the Common Name + if set to `true`. + +## Import + +Certificate packs can be imported using a composite ID of the zone ID and +certificate pack ID. This isn't recommended and it is advised to replace the +certificate entirely instead. + +``` +$ terraform import cloudflare_certificate_pack.example cb029e245cfdd66dc8d2e570d5dd3322/8fda82e2-6af9-4eb2-992a-5ab65b792ef1 +```