diff --git a/go.mod b/go.mod index 76e5ccb..12b8365 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/yugabyte/terraform-provider-ybm go 1.22.7 -toolchain go1.23.1 +toolchain go1.23.4 require ( github.com/golang/mock v1.6.0 diff --git a/managed/fflags/feature_flags.go b/managed/fflags/feature_flags.go index 4285bb5..9cb5dd4 100644 --- a/managed/fflags/feature_flags.go +++ b/managed/fflags/feature_flags.go @@ -16,12 +16,14 @@ const ( CONNECTION_POOLING FeatureFlag = "CONNECTION_POOLING" DR FeatureFlag = "DR" API_KEYS_ALLOW_LIST FeatureFlag = "API_KEYS_ALLOW_LIST" + PITR FeatureFlag = "PITR" ) var flagEnabled = map[FeatureFlag]bool{ CONNECTION_POOLING: false, DR: false, API_KEYS_ALLOW_LIST: false, + PITR: false, } func (f FeatureFlag) String() string { diff --git a/managed/models.go b/managed/models.go index 8d9f7ad..e906955 100644 --- a/managed/models.go +++ b/managed/models.go @@ -402,3 +402,16 @@ type DrConfig struct { TargetClusterId types.String `tfsdk:"target_cluster_id"` Databases []types.String `tfsdk:"databases"` } + +type PitrConfig struct { + AccountId types.String `tfsdk:"account_id"` + ProjectId types.String `tfsdk:"project_id"` + ClusterId types.String `tfsdk:"cluster_id"` + PitrConfigId types.String `tfsdk:"pitr_config_id"` + NamespaceName types.String `tfsdk:"namespace_name"` + NamespaceType types.String `tfsdk:"namespace_type"` + RetentionPeriodInDays types.Int64 `tfsdk:"retention_period_in_days"` + State types.String `tfsdk:"state"` + EarliestRecoveryTimeMillis types.Int64 `tfsdk:"earliest_recovery_time_millis"` + LatestRecoveryTimeMillis types.Int64 `tfsdk:"latest_recovery_time_millis"` +} diff --git a/managed/provider.go b/managed/provider.go index 529040e..00559af 100644 --- a/managed/provider.go +++ b/managed/provider.go @@ -166,6 +166,11 @@ func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceTyp resources["ybm_dr_config"] = resourceDrConfigType{} } + // Add PITR config resource only if the feature flag is enabled + if fflags.IsFeatureFlagEnabled(fflags.PITR) { + resources["ybm_pitr_config"] = resourcePitrConfigType{} + } + return resources, nil } diff --git a/managed/resource_pitr_config.go b/managed/resource_pitr_config.go new file mode 100644 index 0000000..db44f15 --- /dev/null +++ b/managed/resource_pitr_config.go @@ -0,0 +1,423 @@ +/* + * Copyright © 2022-present Yugabyte, Inc. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package managed + +import ( + "context" + "errors" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + retry "github.com/sethvargo/go-retry" + openapiclient "github.com/yugabyte/yugabytedb-managed-go-client-internal" +) + +type resourcePitrConfigType struct{} + +func (r resourcePitrConfigType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: `The resource to create a PITR Config for a namespace in YugabyteDB Aeon.`, + Attributes: map[string]tfsdk.Attribute{ + "account_id": { + Description: "The ID of the account this PITR Config belongs to.", + Type: types.StringType, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + "project_id": { + Description: "The ID of the project this PITR Config belongs to.", + Type: types.StringType, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + "cluster_id": { + Description: "The ID of the cluster this PITR Config belongs to.", + Type: types.StringType, + Required: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + ignoreChangesModifier{}, + }, + }, + "pitr_config_id": { + Description: "The ID of the PITR Config.", + Type: types.StringType, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + "namespace_name": { + Description: "The namespace name for the PITR Config.", + Type: types.StringType, + Required: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + ignoreChangesModifier{}, + }, + }, + "namespace_type": { + Description: "The namespace type.", + Type: types.StringType, + Required: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + ignoreChangesModifier{}, + }, + }, + "retention_period_in_days": { + Description: "The retention period of the PITR Config.", + Type: types.Int64Type, + Required: true, + }, + "state": { + Description: "The status of the association of the cluster with DB query logging config.", + Type: types.StringType, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + "earliest_recovery_time_millis": { + Description: "The earliest recovery time in milliseconds to which the namespace can be restored.", + Type: types.Int64Type, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + "latest_recovery_time_millis": { + Description: "The latest recovery time in milliseconds to which the namespace can be restored.", + Type: types.Int64Type, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{ + tfsdk.UseStateForUnknown(), + }, + }, + }, + }, nil +} + +func (r resourcePitrConfigType) NewResource(_ context.Context, p tfsdk.Provider) (tfsdk.Resource, diag.Diagnostics) { + return resourcePitrConfig{ + p: *(p.(*provider)), + }, nil +} + +type resourcePitrConfig struct { + p provider +} + +func createBulkPitrConfigRequest(apiClient *openapiclient.APIClient, plan PitrConfig) (pitrConfigsRequest *openapiclient.BulkCreateDatabasePitrConfigSpec) { + + pitrConfigSpecs := []openapiclient.DatabasePitrConfigSpec{} + pitrConfigSpecs = append(pitrConfigSpecs, *openapiclient.NewDatabasePitrConfigSpec(openapiclient.YbApiEnum(plan.NamespaceType.Value), plan.NamespaceName.Value, int32(plan.RetentionPeriodInDays.Value))) + + pitrConfigsRequest = openapiclient.NewBulkCreateDatabasePitrConfigSpec() + pitrConfigsRequest.SetPitrConfigSpecs(pitrConfigSpecs) + + return pitrConfigsRequest +} + +func getPitrConfigPlan(ctx context.Context, plan tfsdk.Plan, pitrConfig *PitrConfig) diag.Diagnostics { + var diags diag.Diagnostics + + diags.Append(plan.GetAttribute(ctx, path.Root("namespace_name"), &pitrConfig.NamespaceName)...) + diags.Append(plan.GetAttribute(ctx, path.Root("namespace_type"), &pitrConfig.NamespaceType)...) + diags.Append(plan.GetAttribute(ctx, path.Root("retention_period_in_days"), &pitrConfig.RetentionPeriodInDays)...) + + return diags +} + +func getPitrConfigState(ctx context.Context, state tfsdk.State, pitrConfig *PitrConfig) { + state.GetAttribute(ctx, path.Root("account_id"), &pitrConfig.AccountId) + state.GetAttribute(ctx, path.Root("project_id"), &pitrConfig.ProjectId) + state.GetAttribute(ctx, path.Root("cluster_id"), &pitrConfig.ClusterId) + state.GetAttribute(ctx, path.Root("pitr_config_id"), &pitrConfig.PitrConfigId) + state.GetAttribute(ctx, path.Root("namespace_name"), &pitrConfig.NamespaceName) + state.GetAttribute(ctx, path.Root("namespace_type"), &pitrConfig.NamespaceType) + state.GetAttribute(ctx, path.Root("retention_period_in_days"), &pitrConfig.RetentionPeriodInDays) + state.GetAttribute(ctx, path.Root("state"), &pitrConfig.State) + state.GetAttribute(ctx, path.Root("earliest_recovery_time_millis"), &pitrConfig.EarliestRecoveryTimeMillis) + state.GetAttribute(ctx, path.Root("latest_recovery_time_millis"), &pitrConfig.LatestRecoveryTimeMillis) +} + +// Create PITR Config +func (r resourcePitrConfig) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { + + if !r.p.configured { + resp.Diagnostics.AddError( + "Provider not configured", + "The provider wasn't configured before being applied, likely because it depends on an unknown value from another resource.", + ) + return + } + + var plan PitrConfig + var accountId, message string + var getAccountOK bool + resp.Diagnostics.Append(getPitrConfigPlan(ctx, req.Plan, &plan)...) + if resp.Diagnostics.HasError() { + tflog.Debug(ctx, "Error while getting the plan for the PITR Configs") + return + } + + if (!plan.PitrConfigId.Unknown && !plan.PitrConfigId.Null) || plan.PitrConfigId.Value != "" { + resp.Diagnostics.AddError( + "PITR Config ID provided for new PITR Config", + "The pitr_config_id was provided even though a new PITR Config is being created. Do not include this field in the provider when creating a PITR Config.", + ) + return + } + + apiClient := r.p.client + + accountId, getAccountOK, message = getAccountId(ctx, apiClient) + if !getAccountOK { + resp.Diagnostics.AddError("Unable to get account ID", message) + return + } + + projectId, getProjectOK, message := getProjectId(ctx, apiClient, accountId) + if !getProjectOK { + resp.Diagnostics.AddError("Unable to get project ID ", message) + return + } + + clusterId := plan.ClusterId.Value + + createPitrConfigsRequest := createBulkPitrConfigRequest(apiClient, plan) + + pitrConfigsResp, response, err := apiClient.ClusterApi.CreateDatabasePitrConfig(context.Background(), accountId, projectId, clusterId).BulkCreateDatabasePitrConfigSpec(*createPitrConfigsRequest).Execute() + if err != nil { + errMsg := getErrorMessage(response, err) + resp.Diagnostics.AddError("Unable to create PITR Config ", errMsg) + return + } + + readClusterRetries := 0 + retryPolicy := retry.NewConstant(10 * time.Second) + retryPolicy = retry.WithMaxDuration(3600*time.Second, retryPolicy) + err = retry.Do(ctx, retryPolicy, func(ctx context.Context) error { + asState, readInfoOK, message := getTaskState(accountId, projectId, clusterId, openapiclient.ENTITYTYPEENUM_CLUSTER, openapiclient.TASKTYPEENUM_BULK_ENABLE_DB_PITR, apiClient, ctx) + + tflog.Info(ctx, "PITR config creation operation in progress, state: "+asState) + + if readInfoOK { + if asState == string(openapiclient.TASKACTIONSTATEENUM_SUCCEEDED) { + return nil + } + if asState == string(openapiclient.TASKACTIONSTATEENUM_FAILED) { + return ErrFailedTask + } + } else { + return handleReadFailureWithRetries(ctx, &readClusterRetries, 2, message) + } + return retry.RetryableError(errors.New("PITR config creation operation in progress")) + }) + + if err != nil { + msg := "The operation timed out waiting for PITR config creation to complete." + if errors.Is(err, ErrFailedTask) { + msg = "PITR config creation operation failed" + } + resp.Diagnostics.AddError("Unable to create PITR config:", msg) + return + } + + // Set the computed fields + plan.AccountId = types.String{Value: accountId} + plan.ProjectId = types.String{Value: projectId} + plan.PitrConfigId = types.String{Value: pitrConfigsResp.GetData()[0].Info.GetId()} + plan.State = types.String{Value: pitrConfigsResp.GetData()[0].Info.GetState()} + plan.State = types.String{Value: pitrConfigsResp.GetData()[0].Info.GetState()} + plan.EarliestRecoveryTimeMillis = types.Int64{Value: pitrConfigsResp.GetData()[0].Info.GetEarliestRecoveryTimeMillis()} + plan.LatestRecoveryTimeMillis = types.Int64{Value: pitrConfigsResp.GetData()[0].Info.GetLatestRecoveryTimeMillis()} + + diags := resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read PITR configuration +func (r resourcePitrConfig) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { + var state PitrConfig + getPitrConfigState(ctx, req.State, &state) + + apiClient := r.p.client + accountId := state.AccountId.Value + projectId := state.ProjectId.Value + clusterId := state.ClusterId.Value + pitrConfigId := state.PitrConfigId.Value + + pitrConfigResp, response, err := apiClient.ClusterApi.GetDatabasePitrConfig(ctx, accountId, projectId, clusterId, pitrConfigId).Execute() + if err != nil { + if response != nil && response.StatusCode == 404 { + resp.State.RemoveResource(ctx) + return + } + errMsg := getErrorMessage(response, err) + resp.Diagnostics.AddError("Unable to read PITR configuration", errMsg) + return + } + + // Update state with the current values + state.NamespaceName = types.String{Value: pitrConfigResp.Data.Spec.GetDatabaseName()} + state.NamespaceType = types.String{Value: string(pitrConfigResp.Data.Spec.GetDatabaseType())} + state.RetentionPeriodInDays = types.Int64{Value: int64(pitrConfigResp.Data.Spec.GetRetentionPeriod())} + state.State = types.String{Value: pitrConfigResp.Data.Info.GetState()} + state.EarliestRecoveryTimeMillis = types.Int64{Value: pitrConfigResp.Data.Info.GetEarliestRecoveryTimeMillis()} + state.LatestRecoveryTimeMillis = types.Int64{Value: pitrConfigResp.Data.Info.GetLatestRecoveryTimeMillis()} + + diags := resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update PITR Config +func (r resourcePitrConfig) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) { + var plan PitrConfig + var state PitrConfig + + // Get current state + getPitrConfigState(ctx, req.State, &state) + + // Get planned changes + resp.Diagnostics.Append(getPitrConfigPlan(ctx, req.Plan, &plan)...) + if resp.Diagnostics.HasError() { + tflog.Debug(ctx, "Error while getting the plan for the PITR config edit") + return + } + + // Verify that only retention period field is being changed + if plan.ClusterId.Value != state.ClusterId.Value || plan.NamespaceName.Value != state.NamespaceName.Value || plan.NamespaceType.Value != state.NamespaceType.Value { + resp.Diagnostics.AddError( + "Invalid edit to PITR configuration", + "Only the retention period field can be modified in PITR configurations. Other fields cannot be changed.", + ) + return + } + + apiClient := r.p.client + accountId := state.AccountId.Value + projectId := state.ProjectId.Value + clusterId := state.ClusterId.Value + pitrConfigId := state.PitrConfigId.Value + + // Create edit request with new retention period + _, response, err := apiClient.ClusterApi.UpdateDatabasePitrConfig(ctx, accountId, projectId, clusterId, pitrConfigId).UpdateDatabasePitrConfigSpec(*openapiclient.NewUpdateDatabasePitrConfigSpec(int32(plan.RetentionPeriodInDays.Value))).Execute() + if err != nil { + errMsg := getErrorMessage(response, err) + resp.Diagnostics.AddError("Unable to edit PITR configuration", errMsg) + return + } + + // Wait for to complete + readClusterRetries := 0 + retryPolicy := retry.NewConstant(10 * time.Second) + retryPolicy = retry.WithMaxDuration(3600*time.Second, retryPolicy) + err = retry.Do(ctx, retryPolicy, func(ctx context.Context) error { + asState, readInfoOK, message := getTaskState(accountId, projectId, clusterId, openapiclient.ENTITYTYPEENUM_CLUSTER, openapiclient.TASKTYPEENUM_UPDATE_DB_PITR, apiClient, ctx) + + tflog.Info(ctx, "PITR config edit operation in progress, state: "+asState) + + if readInfoOK { + if asState == string(openapiclient.TASKACTIONSTATEENUM_SUCCEEDED) { + return nil + } + if asState == string(openapiclient.TASKACTIONSTATEENUM_FAILED) { + return ErrFailedTask + } + } else { + return handleReadFailureWithRetries(ctx, &readClusterRetries, 2, message) + } + return retry.RetryableError(errors.New("PITR config edit operation in progress")) + }) + + if err != nil { + msg := "The operation timed out waiting for PITR config edit to complete." + if errors.Is(err, ErrFailedTask) { + msg = "PITR config edit operation failed" + } + resp.Diagnostics.AddError("Unable to edit PITR config:", msg) + return + } + + // Set state to planned new state + plan.AccountId = state.AccountId + plan.ProjectId = state.ProjectId + plan.PitrConfigId = state.PitrConfigId + + diags := resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete PITR configuration +func (r resourcePitrConfig) Delete(ctx context.Context, req tfsdk.DeleteResourceRequest, resp *tfsdk.DeleteResourceResponse) { + var state PitrConfig + getPitrConfigState(ctx, req.State, &state) + accountId := state.AccountId.Value + projectId := state.ProjectId.Value + clusterId := state.ClusterId.Value + pitrConfigId := state.PitrConfigId.Value + + apiClient := r.p.client + + _, err := apiClient.ClusterApi.RemoveDatabasePitrConfig(ctx, accountId, projectId, clusterId, pitrConfigId).Execute() + if err != nil { + resp.Diagnostics.AddError("Unable to delete PITR configuration", GetApiErrorDetails(err)) + return + } + + readClusterRetries := 0 + retryPolicy := retry.NewConstant(10 * time.Second) + retryPolicy = retry.WithMaxDuration(3600*time.Second, retryPolicy) + err = retry.Do(ctx, retryPolicy, func(ctx context.Context) error { + asState, readInfoOK, message := getTaskState(accountId, projectId, clusterId, openapiclient.ENTITYTYPEENUM_CLUSTER, openapiclient.TASKTYPEENUM_DISABLE_DB_PITR, apiClient, ctx) + + tflog.Info(ctx, "PITR config delete operation in progress, state: "+asState) + + if readInfoOK { + if asState == string(openapiclient.TASKACTIONSTATEENUM_SUCCEEDED) { + return nil + } + if asState == string(openapiclient.TASKACTIONSTATEENUM_FAILED) { + return ErrFailedTask + } + } else { + return handleReadFailureWithRetries(ctx, &readClusterRetries, 2, message) + } + return retry.RetryableError(errors.New("PITR config deletion operation in progress")) + }) + + if err != nil { + msg := "The operation timed out waiting for PITR config deletion to complete." + if errors.Is(err, ErrFailedTask) { + msg = "PITR config deletion operation failed" + } + resp.Diagnostics.AddError("Unable to delete PITR config:", msg) + return + } + + resp.State.RemoveResource(ctx) +} + +// Import PITR Config +func (r resourcePitrConfig) ImportState(ctx context.Context, req tfsdk.ImportResourceStateRequest, resp *tfsdk.ImportResourceStateResponse) { + // Save the import identifier in the id attribute + tfsdk.ResourceImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/samples/cluster-single-region.tf b/samples/cluster-single-region.tf index c4f8eb5..17650c2 100644 --- a/samples/cluster-single-region.tf +++ b/samples/cluster-single-region.tf @@ -140,3 +140,13 @@ resource "ybm_read_replicas" "myrr" { # target_cluster_id = "e35dbf4d-cfd7-4e17-b9de-7d4ebd56a0e0" # databases = ["test1", "test2"] # } + +# resource "ybm_pitr_config" "sample_pitr" { +# cluster_id = ybm_cluster.single_region.cluster_id +# namespace_name = "test-PITR-DB" +# namespace_type = "YSQL" +# retention_period_in_days = 7 +# state = "ACTIVE" +# earliest_recovery_time_millis = "1234567889" +# latest_recovery_time_millis = "123456799" +# }