diff --git a/.changelog/5934.txt b/.changelog/5934.txt new file mode 100644 index 0000000000..27a626f764 --- /dev/null +++ b/.changelog/5934.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +privateca: add a new field `desired_state` to manage CertificateAuthority state. +``` diff --git a/google-beta/resource_privateca_certificate_authority.go b/google-beta/resource_privateca_certificate_authority.go index 5633884a23..c4cd85e718 100644 --- a/google-beta/resource_privateca_certificate_authority.go +++ b/google-beta/resource_privateca_certificate_authority.go @@ -15,6 +15,7 @@ package google import ( + "context" "fmt" "log" "reflect" @@ -24,6 +25,31 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +func resourcePrivateCaCACustomDiff(_ context.Context, diff *schema.ResourceDiff, meta interface{}) error { + if diff.HasChange("desired_state") { + _, new := diff.GetChange("desired_state") + + if isNewResource(diff) { + if diff.Get("type").(string) == "SUBORDINATE" { + return fmt.Errorf("`desired_state` can not be specified when creating a SUBORDINATE CA") + } + if new.(string) != "STAGED" && new.(string) != "ENABLED" { + return fmt.Errorf("`desired_state` can only be set to `STAGED` or `ENABLED` when creating a new CA") + } + } else { + if new == "STAGED" && diff.Get("state") != new { + return fmt.Errorf("Field `desired_state` can only be set to `STAGED` when creating a new CA") + } + } + } + return nil +} + +func isNewResource(diff *schema.ResourceDiff) bool { + name := diff.Get("name") + return name.(string) == "" +} + func resourcePrivatecaCertificateAuthority() *schema.Resource { return &schema.Resource{ Create: resourcePrivatecaCertificateAuthorityCreate, @@ -41,6 +67,8 @@ func resourcePrivatecaCertificateAuthority() *schema.Resource { Delete: schema.DefaultTimeout(20 * time.Minute), }, + CustomizeDiff: resourcePrivateCaCACustomDiff, + Schema: map[string]*schema.Schema{ "certificate_authority_id": { Type: schema.TypeString, @@ -601,6 +629,10 @@ fractional digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045 Optional: true, Default: true, }, + "desired_state": { + Type: schema.TypeString, + Optional: true, + }, "project": { Type: schema.TypeString, Optional: true, @@ -719,24 +751,30 @@ func resourcePrivatecaCertificateAuthorityCreate(d *schema.ResourceData, meta in } d.SetId(id) - if d.Get("type").(string) != "SUBORDINATE" { - url, err = replaceVars(d, config, "{{PrivatecaBasePath}}projects/{{project}}/locations/{{location}}/caPools/{{pool}}/certificateAuthorities/{{certificate_authority_id}}:enable") - if err != nil { - return err - } - - log.Printf("[DEBUG] Enabling CertificateAuthority: %#v", obj) - - res, err = sendRequest(config, "POST", billingProject, url, userAgent, nil) - if err != nil { - return fmt.Errorf("Error enabling CertificateAuthority: %s", err) - } - - err = privatecaOperationWaitTimeWithResponse( - config, res, &opRes, project, "Enabling CertificateAuthority", userAgent, - d.Timeout(schema.TimeoutCreate)) - if err != nil { - return fmt.Errorf("Error waiting to enable CertificateAuthority: %s", err) + staged := d.Get("type").(string) == "SELF_SIGNED" + + // Enable the CA if `desired_state` is unspecified or specified as `ENABLED`. + if p, ok := d.GetOk("desired_state"); !ok || p.(string) == "ENABLED" { + // Skip enablement on SUBORDINATE CA for backward compatible. + if staged { + url, err = replaceVars(d, config, "{{PrivatecaBasePath}}projects/{{project}}/locations/{{location}}/caPools/{{pool}}/certificateAuthorities/{{certificate_authority_id}}:enable") + if err != nil { + return err + } + + log.Printf("[DEBUG] Enabling CertificateAuthority: %#v", obj) + + res, err = sendRequest(config, "POST", billingProject, url, userAgent, nil) + if err != nil { + return fmt.Errorf("Error enabling CertificateAuthority: %s", err) + } + + err = privatecaOperationWaitTimeWithResponse( + config, res, &opRes, project, "Enabling CertificateAuthority", userAgent, + d.Timeout(schema.TimeoutCreate)) + if err != nil { + return fmt.Errorf("Error waiting to enable CertificateAuthority: %s", err) + } } } @@ -877,6 +915,56 @@ func resourcePrivatecaCertificateAuthorityUpdate(d *schema.ResourceData, meta in if err != nil { return err } + if d.HasChange("desired_state") { + // Currently, most CA state update operations are not idempotent. + // Try to change state only if the current `state` does not match the `desired_state`. + if p, ok := d.GetOk("desired_state"); ok && p.(string) != d.Get("state").(string) { + switch p.(string) { + case "ENABLED": + enableUrl, err := replaceVars(d, config, "{{PrivatecaBasePath}}projects/{{project}}/locations/{{location}}/caPools/{{pool}}/certificateAuthorities/{{certificate_authority_id}}:enable") + if err != nil { + return err + } + + log.Printf("[DEBUG] Enabling CA: %#v", obj) + + res, err := sendRequest(config, "POST", billingProject, enableUrl, userAgent, nil) + if err != nil { + return fmt.Errorf("Error enabling CA: %s", err) + } + + var opRes map[string]interface{} + err = privatecaOperationWaitTimeWithResponse( + config, res, &opRes, project, "Enabling CA", userAgent, + d.Timeout(schema.TimeoutCreate)) + if err != nil { + return fmt.Errorf("Error waiting to enable CA: %s", err) + } + case "DISABLED": + disableUrl, err := replaceVars(d, config, "{{PrivatecaBasePath}}projects/{{project}}/locations/{{location}}/caPools/{{pool}}/certificateAuthorities/{{certificate_authority_id}}:disable") + if err != nil { + return err + } + + log.Printf("[DEBUG] Disabling CA: %#v", obj) + + dRes, err := sendRequest(config, "POST", billingProject, disableUrl, userAgent, nil) + if err != nil { + return fmt.Errorf("Error disabling CA: %s", err) + } + + var opRes map[string]interface{} + err = privatecaOperationWaitTimeWithResponse( + config, dRes, &opRes, project, "Disabling CA", userAgent, + d.Timeout(schema.TimeoutDelete)) + if err != nil { + return fmt.Errorf("Error waiting to disable CA: %s", err) + } + default: + return fmt.Errorf("Unsupported value in field `desired_state`") + } + } + } // err == nil indicates that the billing_project value was found if bp, err := getBillingProject(d, config); err == nil { diff --git a/google-beta/resource_privateca_certificate_authority_test.go b/google-beta/resource_privateca_certificate_authority_test.go index cee5a1ad4e..18cdc7f421 100644 --- a/google-beta/resource_privateca_certificate_authority_test.go +++ b/google-beta/resource_privateca_certificate_authority_test.go @@ -6,6 +6,85 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) +func TestAccPrivatecaCertificateAuthority_rootCaIsEnabledByDefault(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": randString(t, 10), + } + + resourceName := "google_privateca_certificate_authority.default" + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPrivatecaCertificateAuthorityDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicRoot(context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "ENABLED"), + ), + }, + }, + }) +} + +func TestAccPrivatecaCertificateAuthority_rootCaCreatedInStaged(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": randString(t, 10), + "desired_state": "STAGED", + } + + resourceName := "google_privateca_certificate_authority.default" + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPrivatecaCertificateAuthorityDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "STAGED"), + ), + }, + }, + }) +} + +func TestAccPrivatecaCertificateAuthority_subordinateCaCreatedInAwaitingUserActivation(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": randString(t, 10), + } + + resourceName := "google_privateca_certificate_authority.default" + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPrivatecaCertificateAuthorityDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicSubordinate(context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "AWAITING_USER_ACTIVATION"), + ), + }, + }, + }) +} + func TestAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityUpdate(t *testing.T) { t.Parallel() @@ -22,7 +101,7 @@ func TestAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityUpdate(t CheckDestroy: testAccCheckPrivatecaCertificateAuthorityDestroyProducer(t), Steps: []resource.TestStep{ { - Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityStart(context), + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicRoot(context), }, { ResourceName: "google_privateca_certificate_authority.default", @@ -40,7 +119,7 @@ func TestAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityUpdate(t ImportStateVerifyIgnore: []string{"ignore_active_certificates_on_deletion", "location", "certificate_authority_id", "pool", "deletion_protection"}, }, { - Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityStart(context), + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicRoot(context), }, { ResourceName: "google_privateca_certificate_authority.default", @@ -52,7 +131,69 @@ func TestAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityUpdate(t }) } -func testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityStart(context map[string]interface{}) string { +func TestAccPrivatecaCertificateAuthority_rootCaManageDesiredState(t *testing.T) { + t.Parallel() + + random_suffix := randString(t, 10) + context_staged := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": random_suffix, + "desired_state": "STAGED", + } + + context_enabled := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": random_suffix, + "desired_state": "ENABLED", + } + + context_disabled := map[string]interface{}{ + "pool_name": BootstrapSharedCaPoolInLocation(t, "us-central1"), + "pool_location": "us-central1", + "deletion_protection": false, + "random_suffix": random_suffix, + "desired_state": "DISABLED", + } + + resourceName := "google_privateca_certificate_authority.default" + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPrivatecaCertificateAuthorityDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context_staged), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "STAGED"), + ), + }, + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context_enabled), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "ENABLED"), + ), + }, + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context_disabled), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "DISABLED"), + ), + }, + { + Config: testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context_enabled), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "ENABLED"), + ), + }, + }, + }) +} + +func testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicRoot(context map[string]interface{}) string { return Nprintf(` resource "google_privateca_certificate_authority" "default" { // This example assumes this pool already exists. @@ -160,3 +301,111 @@ resource "google_privateca_certificate_authority" "default" { } `, context) } + +func testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityWithDesiredState(context map[string]interface{}) string { + return Nprintf(` +resource "google_privateca_certificate_authority" "default" { + // This example assumes this pool already exists. + // Pools cannot be deleted in normal test circumstances, so we depend on static pools + pool = "%{pool_name}" + certificate_authority_id = "tf-test-my-certificate-authority-%{random_suffix}" + location = "%{pool_location}" + desired_state = "%{desired_state}" + deletion_protection = false + config { + subject_config { + subject { + organization = "HashiCorp" + common_name = "my-certificate-authority" + } + subject_alt_name { + dns_names = ["hashicorp.com"] + } + } + x509_config { + ca_options { + is_ca = true + max_issuer_path_length = 10 + } + key_usage { + base_key_usage { + digital_signature = true + content_commitment = true + key_encipherment = false + data_encipherment = true + key_agreement = true + cert_sign = true + crl_sign = true + decipher_only = true + } + extended_key_usage { + server_auth = true + client_auth = false + email_protection = true + code_signing = true + time_stamping = true + } + } + } + } + lifetime = "86400s" + key_spec { + algorithm = "RSA_PKCS1_4096_SHA256" + } +} +`, context) +} + +func testAccPrivatecaCertificateAuthority_privatecaCertificateAuthorityBasicSubordinate(context map[string]interface{}) string { + return Nprintf(` +resource "google_privateca_certificate_authority" "default" { + // This example assumes this pool already exists. + // Pools cannot be deleted in normal test circumstances, so we depend on static pools + pool = "%{pool_name}" + certificate_authority_id = "tf-test-my-certificate-authority-%{random_suffix}" + location = "%{pool_location}" + deletion_protection = false + config { + subject_config { + subject { + organization = "HashiCorp" + common_name = "my-certificate-authority" + } + subject_alt_name { + dns_names = ["hashicorp.com"] + } + } + x509_config { + ca_options { + is_ca = true + max_issuer_path_length = 10 + } + key_usage { + base_key_usage { + digital_signature = true + content_commitment = true + key_encipherment = false + data_encipherment = true + key_agreement = true + cert_sign = true + crl_sign = true + decipher_only = true + } + extended_key_usage { + server_auth = true + client_auth = false + email_protection = true + code_signing = true + time_stamping = true + } + } + } + } + lifetime = "86400s" + key_spec { + algorithm = "RSA_PKCS1_4096_SHA256" + } + type = "SUBORDINATE" +} +`, context) +} diff --git a/website/docs/r/privateca_certificate_authority.html.markdown b/website/docs/r/privateca_certificate_authority.html.markdown index 373e166cf2..0d06a03774 100644 --- a/website/docs/r/privateca_certificate_authority.html.markdown +++ b/website/docs/r/privateca_certificate_authority.html.markdown @@ -555,6 +555,8 @@ The following arguments are supported: * `deletion_protection` - (Optional) Whether or not to allow Terraform to destroy the CertificateAuthority. Unless this field is set to false in Terraform state, a `terraform destroy` or `terraform apply` that would delete the instance will fail. +* `desired_state` - (Optional) Desired state of the CertificateAuthority. Set this field to `STAGED` to create a `STAGED` root CA. + ## Attributes Reference