diff --git a/.changelog/3547.txt b/.changelog/3547.txt new file mode 100644 index 00000000000..e24167b6734 --- /dev/null +++ b/.changelog/3547.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +resource/teams-certificate: add teams certificates to Gateway and Gateway account configurations +``` diff --git a/internal/framework/service/gateway_certificates/model.go b/internal/framework/service/gateway_certificates/model.go new file mode 100644 index 00000000000..7ea3b125d96 --- /dev/null +++ b/internal/framework/service/gateway_certificates/model.go @@ -0,0 +1,16 @@ +package gateway_app_types + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type GatewayCertificateModel struct { + ID types.String `tfsdk:"id"` + Custom types.Bool `tfsdk:"custom"` + GatewayManaged types.Bool `tfsdk:"gateway_managed"` + ValidityPeriodDays types.Int64 `tfsdk:"validity_period_days"` + Activate types.Bool `tfsdk:"activate"` + Enabled types.Bool `tfsdk:"enabled"` + BindingStatus types.String `tfsdk:"binding_status"` + ExpiresOn types.String `tfsdk:"expires_on"` +} \ No newline at end of file diff --git a/internal/framework/service/gateway_certificates/resource.go b/internal/framework/service/gateway_certificates/resource.go new file mode 100644 index 00000000000..3cd36ce2609 --- /dev/null +++ b/internal/framework/service/gateway_certificates/resource.go @@ -0,0 +1,138 @@ +package r2_bucket + +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" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &GatewayCertificateModel{} +var _ resource.ResourceWithImportState = &GatewayCertificateModel{} + +func NewResource() resource.Resource { + return &GatewayCertificateModel{} +} + +// GatewayCertificateResource defines the resource implementation. +type GatewayCertificateResource struct { + client *muxclient.Client +} + +func (r *GatewayCertificateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_zero_trust_gateway_certificate" +} + +func (r *GatewayCertificateResource) 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 *GatewayCertificateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *GatewayCertificateModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + cert, err := r.client.V1.TeamsGenerateCertificate(ctx, cfv1.AccountIdentifier(data.AccountID.ValueString()), + cfv1.TeamsCertificateCreateRequest{ + ValidityPeriodDays: data.ValidityPeriodDays.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError("failed to generate Gateway certificate", err.Error()) + return + } + data.ID = types.StringValue(cert.ID) + data.BindingStatus = types.StringValue(cert.BindingStatus) + data.Activate = types.Bool(false) + data.Enabled = types.Bool(cert.Enabled) + data.ExpiresOn = types.StringValue(cert.ExpiresOn) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *GatewayCertificateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *GatewayCertificateModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + r2Bucket, err := r.client.V1.GetR2Bucket(ctx, cfv1.AccountIdentifier(data.AccountID.ValueString()), data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed reading R2 bucket", err.Error()) + return + } + data.ID = types.StringValue(r2Bucket.Name) + data.Name = types.StringValue(r2Bucket.Name) + data.Location = types.StringValue(r2Bucket.Location) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *GatewayCertificateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data *GatewayCertificateModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.AddError("failed to update R2 bucket", "Not implemented") +} + +func (r *GatewayCertificateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *GatewayCertificateModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.V1.DeleteR2Bucket(ctx, cfv1.AccountIdentifier(data.AccountID.ValueString()), data.ID.ValueString()) + + if err != nil { + resp.Diagnostics.AddError("failed to delete R2 bucket", err.Error()) + return + } +} + +func (r *GatewayCertificateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idparts := strings.Split(req.ID, "/") + if len(idparts) != 2 { + resp.Diagnostics.AddError("error importing R2 bucket", "invalid ID specified. Please specify the ID as \"account_id/name\"") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute( + ctx, path.Root("account_id"), idparts[0], + )...) + resp.Diagnostics.Append(resp.State.SetAttribute( + ctx, path.Root("id"), idparts[1], + )...) +} diff --git a/internal/framework/service/gateway_certificates/resource_test.go b/internal/framework/service/gateway_certificates/resource_test.go new file mode 100644 index 00000000000..d45ddf8a12e --- /dev/null +++ b/internal/framework/service/gateway_certificates/resource_test.go @@ -0,0 +1,191 @@ +package r2_bucket_test + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + cfv1 "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/acctest" + "github.com/cloudflare/terraform-provider-cloudflare/internal/utils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestMain(m *testing.M) { + resource.TestMain(m) +} + +func init() { + resource.AddTestSweepers("cloudflare_r2_bucket", &resource.Sweeper{ + Name: "cloudflare_r2_bucket", + F: func(region string) error { + client, err := acctest.SharedV1Client() + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + accessKeyId := os.Getenv("CLOUDFLARE_R2_ACCESS_KEY_ID") + accessKeySecret := os.Getenv("CLOUDFLARE_R2_ACCESS_KEY_SECRET") + + if accessKeyId == "" { + return errors.New("CLOUDFLARE_R2_ACCESS_KEY_ID must be set for this acceptance test") + } + + if accessKeyId == "" { + return errors.New("CLOUDFLARE_R2_ACCESS_KEY_SECRET must be set for this acceptance test") + } + + if err != nil { + return fmt.Errorf("error establishing client: %w", err) + } + + ctx := context.Background() + buckets, err := client.ListR2Buckets(ctx, cfv1.AccountIdentifier(accountID), cfv1.ListR2BucketsParams{}) + if err != nil { + return fmt.Errorf("failed to fetch R2 buckets: %w", err) + } + + for _, bucket := range buckets { + // hard coded bucket name for Worker script acceptance tests + // until we can break out the packages without cyclic errors. + if bucket.Name == "bnfywlzwpt" { + continue + } + + r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID), + }, nil + }) + + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithEndpointResolverWithOptions(r2Resolver), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, "")), + config.WithRegion("auto"), + ) + if err != nil { + return err + } + + s3client := s3.NewFromConfig(cfg) + listObjectsOutput, err := s3client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ + Bucket: &bucket.Name, + }) + if err != nil { + return err + } + + for _, object := range listObjectsOutput.Contents { + _, err = s3client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: &bucket.Name, + Key: object.Key, + }) + if err != nil { + return err + } + } + + err = client.DeleteR2Bucket(ctx, cfv1.AccountIdentifier(accountID), bucket.Name) + if err != nil { + return fmt.Errorf("failed to delete R2 bucket %q: %w", bucket.Name, err) + } + } + + return nil + }, + }) +} + +func TestAccCloudflareR2Bucket_Basic(t *testing.T) { + rnd := utils.GenerateRandomResourceName() + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + resourceName := "cloudflare_r2_bucket." + rnd + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCheckCloudflareR2BucketBasic(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", rnd), + resource.TestCheckResourceAttr(resourceName, "id", rnd), + resource.TestCheckResourceAttr(resourceName, "location", "ENAM"), + ), + }, + { + ResourceName: resourceName, + ImportStateIdPrefix: fmt.Sprintf("%s/", accountID), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccCloudflareR2Bucket_Minimum(t *testing.T) { + rnd := utils.GenerateRandomResourceName() + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + resourceName := "cloudflare_r2_bucket." + rnd + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCheckCloudflareR2BucketMinimum(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", rnd), + resource.TestCheckResourceAttr(resourceName, "id", rnd), + ), + }, + }, + }) +} + +func TestAccCloudflareR2Bucket_InvalidLocation(t *testing.T) { + rnd := utils.GenerateRandomResourceName() + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCheckCloudflareR2BucketInvalidLocation(rnd, accountID), + ExpectError: regexp.MustCompile("Error: Invalid Attribute Value Match"), + }, + }, + }) +} + +func testAccCheckCloudflareR2BucketMinimum(rnd, accountID string) string { + return fmt.Sprintf(` + resource "cloudflare_r2_bucket" "%[1]s" { + account_id = "%[2]s" + name = "%[1]s" + }`, rnd, accountID) +} + +func testAccCheckCloudflareR2BucketBasic(rnd, accountID string) string { + return fmt.Sprintf(` + resource "cloudflare_r2_bucket" "%[1]s" { + account_id = "%[2]s" + name = "%[1]s" + location = "ENAM" + }`, rnd, accountID) +} + +func testAccCheckCloudflareR2BucketInvalidLocation(rnd, accountID string) string { + return fmt.Sprintf(` + resource "cloudflare_r2_bucket" "%[1]s" { + account_id = "%[2]s" + name = "%[1]s" + location = "foo" + }`, rnd, accountID) +} diff --git a/internal/framework/service/gateway_certificates/schema.go b/internal/framework/service/gateway_certificates/schema.go new file mode 100644 index 00000000000..079ef36f9ee --- /dev/null +++ b/internal/framework/service/gateway_certificates/schema.go @@ -0,0 +1,44 @@ +package gateway_app_types + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func (d *CloudflareGatewayAppTypesDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Use this data source to retrieve all Gateway application types for an account.", + Attributes: map[string]schema.Attribute{ + "account_id": schema.StringAttribute{ + Required: true, + Description: "The account ID to fetch Gateway App Types from.", + }, + "app_types": schema.ListNestedAttribute{ + Computed: true, + Description: "A list of Gateway App Types.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + Description: "The identifier for this app type. There is only one app type per ID.", + }, + "application_type_id": schema.Int64Attribute{ + Computed: true, + Description: "The identifier for the application type of this app.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The name of the app type.", + }, + "description": schema.StringAttribute{ + Computed: true, + Description: "A short summary of the app type.", + }, + }, + }, + }, + }, + } +} diff --git a/internal/sdkv2provider/provider.go b/internal/sdkv2provider/provider.go index dbd2bc13d71..42d307c76c7 100644 --- a/internal/sdkv2provider/provider.go +++ b/internal/sdkv2provider/provider.go @@ -21,7 +21,7 @@ import ( const ( MAXIMUM_NUMBER_OF_ENTITIES_REACHED_SUMMARY = "You've attempted to add a new %[1]s to the `terraform-plugin-sdkv2` which is no longer considered suitable for use." MAXIMUM_NUMBER_OF_ENTITIES_REACHED_DETAIL = "Due the number of known internal issues with `terraform-plugin-sdkv2` (most notably handling of zero values), we are no longer recommending using it and instead, advise using `terraform-plugin-framework` exclusively. If you must use terraform-plugin-sdkv2 for this new %[1]s you should first discuss it with a maintainer to fully understand the impact and potential ramifications. Only then should you bump %[2]s to include your %[1]s." - MAXIMUM_ALLOWED_SDKV2_RESOURCES = 145 + MAXIMUM_ALLOWED_SDKV2_RESOURCES = 146 MAXIMUM_ALLOWED_SDKV2_DATASOURCES = 23 ) @@ -291,6 +291,7 @@ func New(version string) func() *schema.Provider { "cloudflare_bot_management": resourceCloudflareBotManagement(), "cloudflare_teams_account": resourceCloudflareTeamsAccount(), "cloudflare_zero_trust_gateway_settings": resourceCloudflareZeroTrustGatewaySettings(), + "cloudflare_zero_trust_gateway_certificate": resourceCloudflareTeamsCertificate(), "cloudflare_teams_list": resourceCloudflareTeamsList(), "cloudflare_zero_trust_list": resourceCloudflareZeroTrustList(), "cloudflare_teams_location": resourceCloudflareTeamsLocation(), diff --git a/internal/sdkv2provider/resource_cloudflare_teams_accounts.go b/internal/sdkv2provider/resource_cloudflare_teams_accounts.go index 17c445b6cce..4eb5700feda 100644 --- a/internal/sdkv2provider/resource_cloudflare_teams_accounts.go +++ b/internal/sdkv2provider/resource_cloudflare_teams_accounts.go @@ -127,6 +127,12 @@ func resourceCloudflareTeamsAccountRead(ctx context.Context, d *schema.ResourceD } } + if configuration.Settings.Certificate != nil { + if err := d.Set("certificate", flattenCertificateConfig(configuration.Settings.Certificate)); err != nil { + return diag.FromErr(fmt.Errorf("error parsing account custom certificate config: %w", err)) + } + } + logSettings, err := client.TeamsAccountLoggingConfiguration(ctx, accountID) if err != nil { return diag.FromErr(fmt.Errorf("error finding Teams Account log settings %q: %w", d.Id(), err)) @@ -183,6 +189,7 @@ func resourceCloudflareTeamsAccountUpdate(ctx context.Context, d *schema.Resourc antivirusConfig := inflateAntivirusConfig(d.Get("antivirus")) extendedEmailMatchingConfig := inflateExtendedEmailMatchingConfig(d.Get("extended_email_matching")) customCertificateConfig := inflateCustomCertificateConfig(d.Get("custom_certificate")) + certificateConfig := inflateCertificateConfig(d.Get("certificate")) loggingConfig := inflateLoggingSettings(d.Get("logging")) deviceConfig := inflateDeviceSettings(d.Get("proxy")) payloadLogSettings := inflatePayloadLogSettings(d.Get("payload_log")) @@ -194,9 +201,14 @@ func resourceCloudflareTeamsAccountUpdate(ctx context.Context, d *schema.Resourc FIPS: fipsConfig, BodyScanning: bodyScanningConfig, ExtendedEmailMatching: extendedEmailMatchingConfig, - CustomCertificate: customCertificateConfig, }, } + if customCertificateConfig != nil { + updatedTeamsAccount.Settings.CustomCertificate = customCertificateConfig + } + if certificateConfig != nil { + updatedTeamsAccount.Settings.Certificate = certificateConfig + } //nolint:staticcheck tlsDecrypt, ok := d.GetOkExists("tls_decrypt_enabled") @@ -550,3 +562,21 @@ func inflateCustomCertificateConfig(config interface{}) *cloudflare.TeamsCustomC ID: configMap["id"].(string), } } + +func flattenCertificateConfig(config *cloudflare.TeamsCertificateSetting) []interface{} { + return []interface{}{map[string]interface{}{ + "id": *&config.ID, + }} +} + +func inflateCertificateConfig(config interface{}) *cloudflare.TeamsCertificateSetting { + list := config.([]interface{}) + if len(list) != 1 { + return nil + } + + configMap := list[0].(map[string]interface{}) + return &cloudflare.TeamsCertificateSetting{ + ID: configMap["id"].(string), + } +} diff --git a/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go b/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go index 4a07f1e0b6c..dc7fb0dd884 100644 --- a/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go +++ b/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go @@ -30,7 +30,7 @@ func TestAccCloudflareTeamsAccounts_ConfigurationBasic(t *testing.T) { Config: testAccCloudflareTeamsAccountBasic(rnd, accountID), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), - resource.TestCheckResourceAttr(name, "custom_certificate.0.enabled", "false"), + resource.TestCheckResourceAttr(name, "certificate.id", "fake-id"), resource.TestCheckResourceAttr(name, "tls_decrypt_enabled", "true"), resource.TestCheckResourceAttr(name, "protocol_detection_enabled", "true"), resource.TestCheckResourceAttr(name, "activity_log_enabled", "true"), @@ -140,8 +140,8 @@ resource "cloudflare_zero_trust_gateway_settings" "%[1]s" { extended_email_matching { enabled = true } - custom_certificate { - enabled = false + certificate { + id = "fake-id" } } `, rnd, accountID) diff --git a/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go b/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go new file mode 100644 index 00000000000..1fc317f5450 --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go @@ -0,0 +1,70 @@ +package sdkv2provider + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudflareTeamsCertificate_Basic(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the Access + // service does not yet support the API tokens and it results in + // misleading state error messages. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_zero_trust_gateway_certificate.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareTeamsCertificateDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareTeamsCertificateManagedBasic(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "binding_status", "inactive"), + resource.TestCheckResourceAttr(name, "gateway_managed", "true"), + ), + }, + }, + }) +} + +func testAccCloudflareTeamsCertificateManagedBasic(rnd, accountID string) string { + return fmt.Sprintf(` +resource "cloudflare_zero_trust_gateway_certificate" "%[1]s" { + account_id = "%[2]s" + gateway_managed = true +} +`, rnd, accountID) +} + +func testAccCheckCloudflareTeamsCertificateDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*cloudflare.API) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_zero_trust_gateway_certificate" { + continue + } + + accountID = rs.Primary.Attributes[consts.AccountIDSchemaKey] + _, err := client.TeamsCertificate(context.Background(), accountID, rs.Primary.ID) + if err == nil { + return fmt.Errorf("Teams Certificate still exists") + } + } + + return nil +} diff --git a/internal/sdkv2provider/resource_cloudflare_teams_certificates.go b/internal/sdkv2provider/resource_cloudflare_teams_certificates.go new file mode 100644 index 00000000000..4205ed58c15 --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_teams_certificates.go @@ -0,0 +1,176 @@ +package sdkv2provider + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/MakeNowJust/heredoc/v2" + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareTeamsCertificate() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareTeamsCertificateSchema(), + CreateContext: resourceCloudflareTeamsCertificateCreate, + UpdateContext: resourceCloudflareTeamsCertificateUpdate, + ReadContext: resourceCloudflareTeamsCertificateRead, + DeleteContext: resourceCloudflareTeamsCertificateDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceCloudflareTeamsCertificateImport, + }, + Description: heredoc.Doc(` + Provides a Cloudflare Teams Gateway Certificate resource. A Teams Certificate can + be specified for Gateway TLS interception and block pages. + `), + } +} + +func resourceCloudflareTeamsCertificateCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + if d.Get("gateway_managed").(bool) { + newTeamsCertificate := cloudflare.TeamsCertificateCreateRequest{ + ValidityPeriodDays: d.Get("validity_period_days").(int), + } + + tflog.Debug(ctx, fmt.Sprintf("Creating Cloudflare Teams Certificate from struct: %+v", newTeamsCertificate)) + + certificate, err := client.TeamsGenerateCertificate(ctx, accountID, newTeamsCertificate) + if err != nil { + return diag.FromErr(fmt.Errorf("error creating Teams Certificate for account %q: %w", accountID, err)) + } + d.SetId(certificate.ID) + } + + if d.Get("activate").(bool) { + certificate, err := client.TeamsActivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error activating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } + + return resourceCloudflareTeamsCertificateRead(ctx, d, meta) +} + +func resourceCloudflareTeamsCertificateUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + if d.Get("activate").(bool) { + certificate, err := client.TeamsActivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error activating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } else { + certificate, err := client.TeamsDeactivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error deactivating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } + + return resourceCloudflareTeamsCertificateRead(ctx, d, meta) +} + +func resourceCloudflareTeamsCertificateRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + Certificate, err := client.TeamsCertificate(ctx, accountID, d.Id()) + + if err != nil { + var notFoundError *cloudflare.NotFoundError + if errors.As(err, ¬FoundError) { + tflog.Info(ctx, fmt.Sprintf("Teams Certificate %s no longer exists", d.Id())) + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error finding Teams Certificate %q: %w", d.Id(), err)) + } + + d.Set("id", Certificate.ID) + d.Set("in_use", Certificate.InUse) + d.Set("binding_status", Certificate.BindingStatus) + d.Set("qs_pack_id", Certificate.QsPackId) + certType := Certificate.Type + if certType == "gateway_managed" { + d.Set("gateway_managed", true) + } else if certType == "custom" { + d.Set("custom", true) + } else { + return diag.FromErr(fmt.Errorf("error reading Teams Certificate type %q: %w", certType, err)) + } + if Certificate.UploadedOn != nil { + d.Set("uploaded_on", Certificate.UploadedOn.Format(time.RFC3339Nano)) + } + if Certificate.CreatedAt != nil { + d.Set("created_at", Certificate.CreatedAt.Format(time.RFC3339Nano)) + } + if Certificate.ExpiresOn != nil { + d.Set("expires_on", Certificate.ExpiresOn.Format(time.RFC3339Nano)) + } + + return nil +} + +func resourceCloudflareTeamsCertificateDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + certID := d.Id() + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + tflog.Debug(ctx, fmt.Sprintf("Deleting Cloudflare Teams Certificate using ID: %s", certID)) + + if d.Get("custom").(bool) { + return diag.FromErr(fmt.Errorf("error deleting Teams Certificate with id %q: custom certificates must be deleted via the mTLS certificate manager api", d.Get("id"))) + } + + if d.Get("active").(bool) { + return diag.FromErr(fmt.Errorf("error deleting Teams Certificate with id %q: certificate must be deactivated before it can be deleted", d.Get("id"))) + } else { + certificate, err := client.TeamsDeactivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error deactivating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + } + + err := client.TeamsDeleteCertificate(ctx, accountID, certID) + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting Teams Certificate for account %q: %w", accountID, err)) + } + + resourceCloudflareTeamsCertificateRead(ctx, d, meta) + + return nil +} + +func resourceCloudflareTeamsCertificateImport(ctx context.Context, 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 \"accountID/teamsCertificateID\"", d.Id()) + } + + accountID, teamsCertificateID := attributes[0], attributes[1] + + tflog.Debug(ctx, fmt.Sprintf("Importing Cloudflare Teams Certificate: id %s for account %s", teamsCertificateID, accountID)) + + d.Set(consts.AccountIDSchemaKey, accountID) + d.SetId(teamsCertificateID) + + resourceCloudflareTeamsCertificateRead(ctx, d, meta) + + return []*schema.ResourceData{d}, nil +} diff --git a/internal/sdkv2provider/schema_cloudflare_teams_accounts.go b/internal/sdkv2provider/schema_cloudflare_teams_accounts.go index d9c6738874b..c8cea88d966 100644 --- a/internal/sdkv2provider/schema_cloudflare_teams_accounts.go +++ b/internal/sdkv2provider/schema_cloudflare_teams_accounts.go @@ -124,12 +124,23 @@ func resourceCloudflareTeamsAccountSchema() map[string]*schema.Schema { }, }, "custom_certificate": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Configuration for custom certificates / BYO-PKI.", + Deprecated: "Use `certificate` instead. Continuing to use custom_certificate may result in inconsistent configuration.", + ConflictsWith: []string{"certificate"}, + Elem: &schema.Resource{ + Schema: customCertificateSchema, + }, + }, + "certificate": { Type: schema.TypeList, MaxItems: 1, Optional: true, - Description: "Configuration for custom certificates / BYO-PKI.", + Description: "Configuration for TLS interception certificate. This will be required starting Feb 2025.", Elem: &schema.Resource{ - Schema: customCertificateSchema, + Schema: certificateSettingSchema, }, }, } @@ -352,3 +363,11 @@ var customCertificateSchema = map[string]*schema.Schema{ Computed: true, }, } + +var certificateSettingSchema = map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + Description: "ID of certificate for TLS interception.", + }, +} diff --git a/internal/sdkv2provider/schema_cloudflare_teams_certificates.go b/internal/sdkv2provider/schema_cloudflare_teams_certificates.go new file mode 100644 index 00000000000..3fc991be0df --- /dev/null +++ b/internal/sdkv2provider/schema_cloudflare_teams_certificates.go @@ -0,0 +1,81 @@ +package sdkv2provider + +import ( + "fmt" + + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceCloudflareTeamsCertificateSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + consts.AccountIDSchemaKey: { + Description: consts.AccountIDSchemaDescription, + Type: schema.TypeString, + Required: true, + }, + "custom": { + Type: schema.TypeBool, + Optional: true, + Description: "The type of certificate (custom or Gateway-managed)", + ExactlyOneOf: []string{"custom", "gateway_managed"}, + }, + "gateway_managed": { + Type: schema.TypeBool, + Optional: true, + Description: "The type of certificate (custom or Gateway-managed)", + ExactlyOneOf: []string{"custom", "gateway_managed"}, + }, + "id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Certificate UUID. Computed for Gateway-managed certificates.", + RequiredWith: []string{"custom"}, + ConflictsWith: []string{"gateway_managed"}, + }, + "validity_period_days": { + Type: schema.TypeInt, + Optional: true, + Description: "Number of days the generated certificate will be valid, minimum 1 day and maximum 30 years. Defaults to 5 years.", + ValidateFunc: validation.IntBetween(1, 10950), + Default: 1826, + RequiredWith: []string{"gateway_managed"}, + ConflictsWith: []string{"custom"}, + ForceNew: true, + }, + "activate": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether or not to activate a certificate. A certificate must be activated to use in Gateway certificate settings", + Default: false, + }, + "in_use": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the certificate is in use by Gateway for TLS interception and the block page", + }, + "binding_status": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("The deployment status of the certificate on the edge %s", renderAvailableDocumentationValuesStringSlice([]string{"IP", "SERIAL", "URL", "DOMAIN", "EMAIL"})), + }, + "qs_pack_id": { + Type: schema.TypeString, + Computed: true, + }, + "uploaded_on": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "expires_on": { + Type: schema.TypeString, + Computed: true, + }, + } +}