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: 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/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/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(), 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) +}