From 2065350b791c37d95c7359779fb4c2e3689f9293 Mon Sep 17 00:00:00 2001 From: DXTimer Date: Tue, 16 Jan 2024 18:18:54 +0200 Subject: [PATCH 1/9] feat: Add new ZoneResource to DnsimpleProvider --- docs/resources/zone.md | 60 ++++ internal/framework/provider/provider.go | 1 + internal/framework/resources/zone_resource.go | 278 ++++++++++++++++++ .../framework/resources/zone_resource_test.go | 89 ++++++ 4 files changed, 428 insertions(+) create mode 100644 docs/resources/zone.md create mode 100644 internal/framework/resources/zone_resource.go create mode 100644 internal/framework/resources/zone_resource_test.go diff --git a/docs/resources/zone.md b/docs/resources/zone.md new file mode 100644 index 00000000..c241d784 --- /dev/null +++ b/docs/resources/zone.md @@ -0,0 +1,60 @@ +--- +page_title: "DNSimple: dnsimple_zone" +--- + +# dnsimple\_zone + +Provides a DNSimple zone resource. + +-> Currently the resource creation acts as an import, so the zone must already exist in DNSimple. The only attribute that will be modified during resource creation is the `active` state of the zone. This is because our API does not allow for the creation of zones. Creation of zones happens through the purchase or creation of domains. We expect this behavior to change in the future. + +## Example Usage + +```hcl +# Create a zone +resource "dnsimple_zone" "foobar" { + name = "${var.dnsimple.zone}" +} +``` + +## Argument Reference + +The following argument(s) are supported: + +* `name` - (Required) The zone name + +# Attributes Reference + +- `id` - The ID of this resource. +- `account_id` - The account ID for the zone. +- `reverse` - Whether the zone is a reverse zone. +- `secondary` - Whether the zone is a secondary zone. +- `active` - Whether the zone is active. +- `last_transferred_at` - The last time the zone was transferred only applicable for **secondary** zones. + +## Import + +DNSimple zones can be imported using their numeric record ID or the zone name. + +```bash +terraform import dnsimple_zone.resource_name foo.com +``` + +The zone ID can be found within [DNSimple Zones API](https://developer.dnsimple.com/v2/zones/#getZone). Check out [Authentication](https://developer.dnsimple.com/v2/#authentication) in API Overview for available options. + +```bash +curl -H 'Authorization: Bearer ' https://api.dnsimple.com/v2/1234/zones/example.com | jq +{ + "data": { + "id": 1, + "account_id": 1234, + "name": "example.com", + "reverse": false, + "secondary": false, + "last_transferred_at": null, + "active": true, + "created_at": "2023-04-18T04:58:01Z", + "updated_at": "2024-01-16T15:53:18Z" + } +} +``` diff --git a/internal/framework/provider/provider.go b/internal/framework/provider/provider.go index 72cb2b2a..3b8596fe 100644 --- a/internal/framework/provider/provider.go +++ b/internal/framework/provider/provider.go @@ -176,6 +176,7 @@ func (p *DnsimpleProvider) Resources(ctx context.Context) []func() resource.Reso resources.NewEmailForwardResource, resources.NewLetsEncryptCertificateResource, resources.NewZoneRecordResource, + resources.NewZoneResource, } } diff --git a/internal/framework/resources/zone_resource.go b/internal/framework/resources/zone_resource.go new file mode 100644 index 00000000..12a1e6da --- /dev/null +++ b/internal/framework/resources/zone_resource.go @@ -0,0 +1,278 @@ +package resources + +import ( + "context" + "errors" + "fmt" + + "github.com/dnsimple/dnsimple-go/dnsimple" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/common" + "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/utils" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &ZoneResource{} + _ resource.ResourceWithConfigure = &ZoneResource{} + _ resource.ResourceWithImportState = &ZoneResource{} +) + +func NewZoneResource() resource.Resource { + return &ZoneResource{} +} + +// ZoneResource defines the resource implementation. +type ZoneResource struct { + config *common.DnsimpleProviderConfig +} + +// ZoneResourceModel describes the resource data model. +type ZoneResourceModel struct { + Name types.String `tfsdk:"name"` + AccountId types.Int64 `tfsdk:"account_id"` + Reverse types.Bool `tfsdk:"reverse"` + Secondary types.Bool `tfsdk:"secondary"` + Active types.Bool `tfsdk:"active"` + LastTransferredAt types.String `tfsdk:"last_transferred_at"` + Id types.Int64 `tfsdk:"id"` +} + +func (r *ZoneResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_zone" +} + +func (r *ZoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "DNSimple zone resource", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "account_id": schema.Int64Attribute{ + Computed: true, + }, + "reverse": schema.BoolAttribute{ + Computed: true, + }, + "secondary": schema.BoolAttribute{ + Computed: true, + }, + "active": schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + "last_transferred_at": schema.StringAttribute{ + Computed: true, + }, + "id": common.IDInt64Attribute(), + }, + } +} + +func (r *ZoneResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + config, ok := req.ProviderData.(*common.DnsimpleProviderConfig) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.DnsimpleProviderConfig, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.config = config +} + +func (r *ZoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *ZoneResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + response, err := r.config.Client.Zones.GetZone(ctx, r.config.AccountID, data.Name.ValueString()) + + if err != nil { + var errorResponse *dnsimple.ErrorResponse + if errors.As(err, &errorResponse) { + resp.Diagnostics.Append(utils.AttributeErrorsToDiagnostics(errorResponse)...) + return + } + + resp.Diagnostics.AddError( + "failed to retrieve DNSimple Zone", + err.Error(), + ) + return + } + + if !(data.Active.IsUnknown() || data.Active.IsNull()) && data.Active.ValueBool() != response.Data.Active { + zone, diags := r.setActiveState(ctx, data) + + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + r.updateModelFromAPIResponse(zone, data) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + return + } + + r.updateModelFromAPIResponse(response.Data, data) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ZoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *ZoneResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + response, err := r.config.Client.Zones.GetZone(ctx, r.config.AccountID, data.Name.ValueString()) + + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("failed to read DNSimple Zone: %s", data.Name.ValueString()), + err.Error(), + ) + return + } + + r.updateModelFromAPIResponse(response.Data, data) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ZoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var ( + configData *ZoneResourceModel + planData *ZoneResourceModel + stateData *ZoneResourceModel + ) + + resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.Get(ctx, &stateData)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &configData)...) + if resp.Diagnostics.HasError() { + return + } + + if !(planData.Active.IsUnknown() || planData.Active.IsNull()) && planData.Active.ValueBool() != stateData.Active.ValueBool() { + zone, diags := r.setActiveState(ctx, planData) + + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + r.updateModelFromAPIResponse(zone, planData) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &planData)...) + + return + } +} + +func (r *ZoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *ZoneResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Warn(ctx, fmt.Sprintf("Removing DNSimple Zone from Terraform state only: %s, %s", data.Name, data.Id)) +} + +func (r *ZoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + response, err := r.config.Client.Zones.GetZone(ctx, r.config.AccountID, req.ID) + + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("failed to find DNSimple Zone ID: %s", req.ID), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), response.Data.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), response.Data.Name)...) +} + +func (r *ZoneResource) updateModelFromAPIResponse(zone *dnsimple.Zone, data *ZoneResourceModel) { + data.Id = types.Int64Value(zone.ID) + data.Name = types.StringValue(zone.Name) + data.AccountId = types.Int64Value(zone.AccountID) + data.Reverse = types.BoolValue(zone.Reverse) + data.Secondary = types.BoolValue(zone.Secondary) + data.Active = types.BoolValue(zone.Active) + data.LastTransferredAt = types.StringValue(zone.LastTransferredAt) +} + +func (r *ZoneResource) setActiveState(ctx context.Context, data *ZoneResourceModel) (*dnsimple.Zone, diag.Diagnostics) { + diagnostics := diag.Diagnostics{} + + tflog.Debug(ctx, fmt.Sprintf("setting active to %t", data.Active.ValueBool())) + + if data.Active.ValueBool() { + zoneResponse, err := r.config.Client.Zones.ActivateZoneDns(ctx, r.config.AccountID, data.Name.ValueString()) + if err != nil { + diagnostics.AddError( + fmt.Sprintf("failed to activate DNSimple Zone: %s, %d", data.Name.ValueString(), data.Id.ValueInt64()), + err.Error(), + ) + } + return zoneResponse.Data, diagnostics + } + + zoneResponse, err := r.config.Client.Zones.DeactivateZoneDns(ctx, r.config.AccountID, data.Name.ValueString()) + if err != nil { + diagnostics.AddError( + fmt.Sprintf("failed to deactivate DNSimple Zone: %s, %d", data.Name.ValueString(), data.Id.ValueInt64()), + err.Error(), + ) + } + + return zoneResponse.Data, diagnostics +} diff --git a/internal/framework/resources/zone_resource_test.go b/internal/framework/resources/zone_resource_test.go new file mode 100644 index 00000000..f4f44195 --- /dev/null +++ b/internal/framework/resources/zone_resource_test.go @@ -0,0 +1,89 @@ +package resources_test + +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + _ "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/resources" + "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/test_utils" +) + +func TestAccZoneResource(t *testing.T) { + zoneName := os.Getenv("DNSIMPLE_DOMAIN") + resourceName := "dnsimple_zone.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { test_utils.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccZoneResourceConfig(zoneName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", zoneName), + resource.TestCheckResourceAttr(resourceName, "reverse", "false"), + resource.TestCheckResourceAttr(resourceName, "secondary", "false"), + resource.TestCheckResourceAttr(resourceName, "active", "true"), + ), + }, + { + Config: testAccZoneResourceConfigWithActive(zoneName, false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", zoneName), + resource.TestCheckResourceAttr(resourceName, "reverse", "false"), + resource.TestCheckResourceAttr(resourceName, "secondary", "false"), + resource.TestCheckResourceAttr(resourceName, "active", "false"), + ), + }, + { + Config: testAccZoneResourceConfigWithActive(zoneName, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", zoneName), + resource.TestCheckResourceAttr(resourceName, "reverse", "false"), + resource.TestCheckResourceAttr(resourceName, "secondary", "false"), + resource.TestCheckResourceAttr(resourceName, "active", "true"), + ), + }, + { + ResourceName: resourceName, + ImportStateIdFunc: testAccZoneImportStateIDFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccZoneImportStateIDFunc(resourceName string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("Resource not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return "", errors.New("No resource ID set") + } + + return rs.Primary.ID, nil + } +} + +func testAccZoneResourceConfig(zoneName string) string { + return fmt.Sprintf(` +resource "dnsimple_zone" "test" { + name = %[1]q +}`, zoneName) +} + +func testAccZoneResourceConfigWithActive(zoneName string, active bool) string { + return fmt.Sprintf(` +resource "dnsimple_zone" "test" { + name = %[1]q + active = %[2]t +}`, zoneName, active) +} From a8195f77220089227e85a43b0b342638e4b0ffcb Mon Sep 17 00:00:00 2001 From: DXTimer Date: Tue, 16 Jan 2024 18:19:59 +0200 Subject: [PATCH 2/9] deprecate dnsimple_zone datasource in favor of dnsimple_zone resource --- docs/data-sources/zone.md | 2 ++ internal/framework/datasources/zone_data_source.go | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/data-sources/zone.md b/docs/data-sources/zone.md index 5a9242ad..ea74d442 100644 --- a/docs/data-sources/zone.md +++ b/docs/data-sources/zone.md @@ -6,6 +6,8 @@ page_title: "DNSimple: dnsimple_zone" Get information about a DNSimple zone. +!> Data source is getting deprecated in favor of [`dnsimple\_zone`](../resources/zone.md) resource. + # Example Usage Get zone: diff --git a/internal/framework/datasources/zone_data_source.go b/internal/framework/datasources/zone_data_source.go index 87bdb4b3..c09db8b5 100644 --- a/internal/framework/datasources/zone_data_source.go +++ b/internal/framework/datasources/zone_data_source.go @@ -38,6 +38,7 @@ func (d *ZoneDataSource) Schema(ctx context.Context, req datasource.SchemaReques resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. MarkdownDescription: "DNSimple zone data source", + DeprecationMessage: "This data source is deprecated. Please use the dnsimple_zone resource instead.", Attributes: map[string]schema.Attribute{ "id": common.IDInt64Attribute(), From 07489c62d26e936ea2c34354dea06b1f38d9c42a Mon Sep 17 00:00:00 2001 From: DXTimer Date: Tue, 16 Jan 2024 18:29:23 +0200 Subject: [PATCH 3/9] update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index affd7145..0ae8567b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## main +FEATURES: + +- **New Resource:** `dnsimple_zone` (dnsimple/terraform-provider-dnsimple#184) + +NOTES: + +- The `dnsimple_zone` data source is now deprecated and will be removed in a future release. Please migrate to the `dnsimple_zone` resource. + ## 1.3.1 BUG FIXES: From b472b4658e1b9c48ef22b7e4f76828b6abf06ebb Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 17 Jan 2024 02:48:44 -0500 Subject: [PATCH 4/9] add data source timeout module --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 7d8be0e6..ed6514a7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ require ( github.com/dnsimple/dnsimple-go v1.5.1 github.com/hashicorp/terraform-plugin-docs v0.16.0 github.com/hashicorp/terraform-plugin-framework v1.4.2 + github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 github.com/hashicorp/terraform-plugin-go v0.20.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-mux v0.13.0 diff --git a/go.sum b/go.sum index 6ce45a3a..817f893c 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFcc github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= github.com/hashicorp/terraform-plugin-framework v1.4.2 h1:P7a7VP1GZbjc4rv921Xy5OckzhoiO3ig6SGxwelD2sI= github.com/hashicorp/terraform-plugin-framework v1.4.2/go.mod h1:GWl3InPFZi2wVQmdVnINPKys09s9mLmTZr95/ngLnbY= +github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 h1:gm5b1kHgFFhaKFhm4h2TgvMUlNzFAtUqlcOWnWPm+9E= +github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1/go.mod h1:MsjL1sQ9L7wGwzJ5RjcI6FzEMdyoBnw+XK8ZnOvQOLY= github.com/hashicorp/terraform-plugin-go v0.20.0 h1:oqvoUlL+2EUbKNsJbIt3zqqZ7wi6lzn4ufkn/UA51xQ= github.com/hashicorp/terraform-plugin-go v0.20.0/go.mod h1:Rr8LBdMlY53a3Z/HpP+ZU3/xCDqtKNCkeI9qOyT10QE= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= From d9e331a06ede4db10034204c59caf73bf8b8d1cc Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 17 Jan 2024 02:50:03 -0500 Subject: [PATCH 5/9] add timeout with retry to certificate data source --- .../datasources/certificate_data_source.go | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/internal/framework/datasources/certificate_data_source.go b/internal/framework/datasources/certificate_data_source.go index b736c9be..21fb91be 100644 --- a/internal/framework/datasources/certificate_data_source.go +++ b/internal/framework/datasources/certificate_data_source.go @@ -5,10 +5,13 @@ import ( "fmt" "time" + "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/common" + "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/utils" ) // Ensure provider defined types fully satisfy framework interfaces. @@ -25,13 +28,14 @@ type CertificateDataSource struct { // CertificateDataSourceModel describes the data source data model. type CertificateDataSourceModel struct { - Id types.String `tfsdk:"id"` - CertificateId types.Int64 `tfsdk:"certificate_id"` - Domain types.String `tfsdk:"domain"` - ServerCertificate types.String `tfsdk:"server_certificate"` - RootCertificate types.String `tfsdk:"root_certificate"` - CertificateChain types.List `tfsdk:"certificate_chain"` - PrivateKey types.String `tfsdk:"private_key"` + Id types.String `tfsdk:"id"` + CertificateId types.Int64 `tfsdk:"certificate_id"` + Domain types.String `tfsdk:"domain"` + ServerCertificate types.String `tfsdk:"server_certificate"` + RootCertificate types.String `tfsdk:"root_certificate"` + CertificateChain types.List `tfsdk:"certificate_chain"` + PrivateKey types.String `tfsdk:"private_key"` + Timeouts timeouts.Value `tfsdk:"timeouts"` } func (d *CertificateDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -71,6 +75,9 @@ func (d *CertificateDataSource) Schema(ctx context.Context, req datasource.Schem Computed: true, }, }, + Blocks: map[string]schema.Block{ + "timeouts": timeouts.Block(ctx), + }, } } @@ -104,38 +111,54 @@ func (d *CertificateDataSource) Read(ctx context.Context, req datasource.ReadReq return } - response, err := d.config.Client.Certificates.DownloadCertificate(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) + readTimeout, diags := data.Timeouts.Read(ctx, 10*time.Minute) - if err != nil { - resp.Diagnostics.AddError( - "failed to download DNSimple Certificate", - err.Error(), - ) - return - } + resp.Diagnostics.Append(diags...) - data.ServerCertificate = types.StringValue(response.Data.ServerCertificate) - data.RootCertificate = types.StringValue(response.Data.RootCertificate) - chain, diag := types.ListValueFrom(ctx, types.StringType, response.Data.IntermediateCertificates) - if err != nil { - resp.Diagnostics.Append(diag...) + if resp.Diagnostics.HasError() { return } - data.CertificateChain = chain - response, err = d.config.Client.Certificates.GetCertificatePrivateKey(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) + err := utils.RetryWithTimeout(ctx, func() (error, bool) { + + response, err := d.config.Client.Certificates.DownloadCertificate(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) + + if err != nil { + tflog.Info(ctx, "[RETRYING] Failed to download certificate") + return err, false + } + + data.ServerCertificate = types.StringValue(response.Data.ServerCertificate) + data.RootCertificate = types.StringValue(response.Data.RootCertificate) + chain, diag := types.ListValueFrom(ctx, types.StringType, response.Data.IntermediateCertificates) + if err != nil { + resp.Diagnostics.Append(diag...) + return err, false + } + data.CertificateChain = chain + + response, err = d.config.Client.Certificates.GetCertificatePrivateKey(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) + + if err != nil { + tflog.Info(ctx, "[RETRYING] Failed to download private key.") + return err, false + } + + data.PrivateKey = types.StringValue(response.Data.PrivateKey) + data.Id = types.StringValue(time.Now().UTC().String()) + + tflog.Info(ctx, "[RETRYING] Certificate has not yet been issued.") + return nil, false + }, readTimeout, 60*time.Second) if err != nil { resp.Diagnostics.AddError( - "failed to download DNSimple Certificate private key", + "failed to download certificate", err.Error(), ) return } - data.PrivateKey = types.StringValue(response.Data.PrivateKey) - data.Id = types.StringValue(time.Now().UTC().String()) - // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } From 8cb715e05fa914c666481d76e75f66c8a1858e69 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 17 Jan 2024 02:54:14 -0500 Subject: [PATCH 6/9] add certificate data source timeout to docs --- docs/data-sources/certificate.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/data-sources/certificate.md b/docs/data-sources/certificate.md index 35d6ecd6..4fca8c94 100644 --- a/docs/data-sources/certificate.md +++ b/docs/data-sources/certificate.md @@ -30,3 +30,11 @@ The following attributes are exported: * `root_certificate` - The Root Certificate of the issuing CA * `certificate_chain` - A list of certificates that make up the chain * `private_key` - The corresponding Private Key for the SSL Certificate + + + +### Nested Schema for `timeouts` + +Optional: + +- `read` (String) - The timeout for the read operation e.g. `5m` From 427666aaffc9ce8475fd0452339ec9bd5ce5dcb7 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 17 Jan 2024 11:29:12 -0500 Subject: [PATCH 7/9] fix changelog merge conflicts --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0dbda0..aea502b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## main +## 1.4.0 + FEATURES: - **New Resource:** `dnsimple_zone` (dnsimple/terraform-provider-dnsimple#184) @@ -9,7 +11,7 @@ FEATURES: NOTES: - The `dnsimple_zone` data source is now deprecated and will be removed in a future release. Please migrate to the `dnsimple_zone` resource. - +- This Go module has been updated to Go 1.20. ## 1.3.1 From d884921f7b0391b46dfad10da55bd6d48e6007b9 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Thu, 18 Jan 2024 16:26:53 -0500 Subject: [PATCH 8/9] update certificate retry function error handling --- .../datasources/certificate_data_source.go | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/internal/framework/datasources/certificate_data_source.go b/internal/framework/datasources/certificate_data_source.go index 21fb91be..9582d6cb 100644 --- a/internal/framework/datasources/certificate_data_source.go +++ b/internal/framework/datasources/certificate_data_source.go @@ -8,12 +8,20 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/terraform-providers/terraform-provider-dnsimple/internal/consts" "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/common" "github.com/terraform-providers/terraform-provider-dnsimple/internal/framework/utils" ) +const ( + CertificateConverged = "certificate_converged" + CertificateFailed = "certificate_failed" + CertificateTimeout = "certificate_timeout" +) + // Ensure provider defined types fully satisfy framework interfaces. var _ datasource.DataSource = &CertificateDataSource{} @@ -102,7 +110,7 @@ func (d *CertificateDataSource) Configure(ctx context.Context, req datasource.Co } func (d *CertificateDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data CertificateDataSourceModel + var data *CertificateDataSourceModel // Read Terraform configuration data into the model resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) @@ -111,21 +119,31 @@ func (d *CertificateDataSource) Read(ctx context.Context, req datasource.ReadReq return } - readTimeout, diags := data.Timeouts.Read(ctx, 10*time.Minute) + convergenceState, err := tryToConvergeCertificate(ctx, data, &resp.Diagnostics, d, data.CertificateId.ValueInt64()) - resp.Diagnostics.Append(diags...) + if err != nil { + resp.Diagnostics.AddError( + "failed to get certificate state", + err.Error(), + ) + return + } - if resp.Diagnostics.HasError() { + if convergenceState == CertificateFailed || convergenceState == CertificateTimeout { + // Response is already populated with the error we can safely return return } - err := utils.RetryWithTimeout(ctx, func() (error, bool) { + if convergenceState == CertificateConverged { response, err := d.config.Client.Certificates.DownloadCertificate(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) if err != nil { - tflog.Info(ctx, "[RETRYING] Failed to download certificate") - return err, false + resp.Diagnostics.AddError( + "failed to download DNSimple Certificate", + err.Error(), + ) + return } data.ServerCertificate = types.StringValue(response.Data.ServerCertificate) @@ -133,32 +151,79 @@ func (d *CertificateDataSource) Read(ctx context.Context, req datasource.ReadReq chain, diag := types.ListValueFrom(ctx, types.StringType, response.Data.IntermediateCertificates) if err != nil { resp.Diagnostics.Append(diag...) - return err, false + return } data.CertificateChain = chain response, err = d.config.Client.Certificates.GetCertificatePrivateKey(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) if err != nil { - tflog.Info(ctx, "[RETRYING] Failed to download private key.") - return err, false + resp.Diagnostics.AddError( + "failed to download DNSimple Certificate private key", + err.Error(), + ) + return } data.PrivateKey = types.StringValue(response.Data.PrivateKey) data.Id = types.StringValue(time.Now().UTC().String()) - tflog.Info(ctx, "[RETRYING] Certificate has not yet been issued.") + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + } +} + +func tryToConvergeCertificate(ctx context.Context, data *CertificateDataSourceModel, diagnostics *diag.Diagnostics, d *CertificateDataSource, certificateID int64) (string, error) { + readTimeout, diags := data.Timeouts.Read(ctx, 5*time.Minute) + + diagnostics.Append(diags...) + + if diagnostics.HasError() { + return CertificateFailed, nil + } + + err := utils.RetryWithTimeout(ctx, func() (error, bool) { + + certificate, err := d.config.Client.Certificates.GetCertificate(ctx, d.config.AccountID, data.Domain.ValueString(), data.CertificateId.ValueInt64()) + + if err != nil { + return err, false + } + + if certificate.Data.State == consts.CertificateStateFailed { + diagnostics.AddError( + fmt.Sprintf("failed to issue certificate: %s", data.Domain.ValueString()), + "certificate order failed, please investigate why this happened. If you need assistance, please contact support at support@dnsimple.com", + ) + return nil, true + } + + if certificate.Data.State == consts.CertificateStateCancelled || certificate.Data.State == consts.CertificateStateRefunded { + diagnostics.AddError( + fmt.Sprintf("failed to issue certificate: %s", data.Domain.ValueString()), + "certificate order failed, please investigate why this happened. If you need assistance, please contact support at support@dnsimple.com", + ) + return nil, true + } + + if certificate.Data.State != consts.CertificateStateIssued { + tflog.Info(ctx, fmt.Sprintf("[RETRYING] Certificate order is not complete, current state: %s", certificate.Data.State)) + + return fmt.Errorf("certificate has not been issued, current state: %s. You can try to run terraform again to try and converge the certificate", certificate.Data.State), false + } + return nil, false - }, readTimeout, 60*time.Second) + }, readTimeout, 20*time.Second) + + if diagnostics.HasError() { + // If we have diagnostic errors, we suspended the retry loop because the certificate is in a bad state, and cannot converge. + return CertificateFailed, nil + } if err != nil { - resp.Diagnostics.AddError( - "failed to download certificate", - err.Error(), - ) - return + // If we have an error, it means the retry loop timed out, and we cannot converge during this run. + return CertificateTimeout, err } - // Save data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return CertificateConverged, nil } From 257c2575c7e0de57862af19edf1f041af6cbe112 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Thu, 18 Jan 2024 16:29:12 -0500 Subject: [PATCH 9/9] add certificate state constants --- internal/consts/provider.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/consts/provider.go b/internal/consts/provider.go index cd7e9027..cab6e246 100644 --- a/internal/consts/provider.go +++ b/internal/consts/provider.go @@ -1,7 +1,15 @@ package consts const ( - BaseURLSandbox = "https://api.sandbox.dnsimple.com" + BaseURLSandbox = "https://api.sandbox.dnsimple.com" + + // Certificate states + CertificateStateCancelled = "cancelled" + CertificateStateFailed = "failed" + CertificateStateIssued = "issued" + CertificateStateRefunded = "refunded" + + // Domain states DomainStateRegistered = "registered" DomainStateHosted = "hosted" DomainStateNew = "new"