diff --git a/.golangci.yaml b/.golangci.yaml index 4cb88230..3f30e61b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -19,7 +19,7 @@ linters: - errname - errorlint - exhaustive - - exportloopref + - copyloopvar - forbidigo - forcetypeassert - gocheckcompilerdirectives diff --git a/.mise.toml b/.mise.toml index 814da708..24a3fbca 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,7 +3,7 @@ golang = '1.21.10' golangci-lint = '1.61.0' goreleaser = '1.26.2' pre-commit = '3.7.1' -terraform = '1.8.4' +terraform = '1.9.8' # tomlv does not currently have an entry in the registry. # For now, install with `go`: diff --git a/create-dev-testfile.sh b/create-dev-testfile.sh index 042ed516..cee71170 100644 --- a/create-dev-testfile.sh +++ b/create-dev-testfile.sh @@ -36,9 +36,12 @@ provider "prefect" {} resource "${resource}" "${name}" {} EOF + cmd="cd ${dev_file_target} && terraform plan" echo "" echo "run:" - echo "cd ${dev_file_target} && terraform plan" + echo "${cmd}" + echo "(copied to clipboard)" + printf "${cmd}" | pbcopy } main $@ diff --git a/internal/provider/resources/variable.go b/internal/provider/resources/variable.go index 820ac782..eab42785 100644 --- a/internal/provider/resources/variable.go +++ b/internal/provider/resources/variable.go @@ -27,6 +27,7 @@ import ( var ( _ = resource.ResourceWithConfigure(&VariableResource{}) _ = resource.ResourceWithImportState(&VariableResource{}) + _ = resource.ResourceWithUpgradeState(&VariableResource{}) ) // VariableResource contains state for the resource. @@ -35,7 +36,26 @@ type VariableResource struct { } // VariableResourceModel defines the Terraform resource model. -type VariableResourceModel struct { +// NOTE: we version the VersionResourceModel here due to a schema +// update, and we want to be able to properly migrate existing +// prefect_variable resources in state from one schema version to the next. +// See UpgradeState for more details. +// +// V0: Value is types.String. +type VariableResourceModelV0 struct { + ID types.String `tfsdk:"id"` + Created customtypes.TimestampValue `tfsdk:"created"` + Updated customtypes.TimestampValue `tfsdk:"updated"` + AccountID customtypes.UUIDValue `tfsdk:"account_id"` + WorkspaceID customtypes.UUIDValue `tfsdk:"workspace_id"` + + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` + Tags types.List `tfsdk:"tags"` +} + +// V1: Value is types.Dynamic. +type VariableResourceModelV1 struct { ID types.String `tfsdk:"id"` Created customtypes.TimestampValue `tfsdk:"created"` Updated customtypes.TimestampValue `tfsdk:"updated"` @@ -47,6 +67,59 @@ type VariableResourceModel struct { Tags types.List `tfsdk:"tags"` } +var defaultEmptyTagList, _ = basetypes.NewListValue(types.StringType, []attr.Value{}) + +var VariableResourceSchemaAttributes = map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + // We cannot use a CustomType due to a conflict with PlanModifiers; see + // https://github.com/hashicorp/terraform-plugin-framework/issues/763 + // https://github.com/hashicorp/terraform-plugin-framework/issues/754 + Description: "Variable ID (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created": schema.StringAttribute{ + Computed: true, + CustomType: customtypes.TimestampType{}, + Description: "Timestamp of when the resource was created (RFC3339)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "updated": schema.StringAttribute{ + Computed: true, + CustomType: customtypes.TimestampType{}, + Description: "Timestamp of when the resource was updated (RFC3339)", + }, + "account_id": schema.StringAttribute{ + CustomType: customtypes.UUIDType{}, + Description: "Account ID (UUID), defaults to the account set in the provider", + Optional: true, + }, + "workspace_id": schema.StringAttribute{ + CustomType: customtypes.UUIDType{}, + Description: "Workspace ID (UUID), defaults to the workspace set in the provider", + Optional: true, + }, + "name": schema.StringAttribute{ + Description: "Name of the variable", + Required: true, + }, + "value": schema.DynamicAttribute{ + Description: "Value of the variable, supported Terraform value types: string, number, bool, tuple, object", + Required: true, + }, + "tags": schema.ListAttribute{ + Description: "Tags associated with the variable", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(defaultEmptyTagList), + }, +} + // NewVariableResource returns a new VariableResource. // //nolint:ireturn // required by Terraform API @@ -77,60 +150,70 @@ func (r *VariableResource) Configure(_ context.Context, req resource.ConfigureRe // Schema defines the schema for the resource. func (r *VariableResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - defaultEmptyTagList, _ := basetypes.NewListValue(types.StringType, []attr.Value{}) - resp.Schema = schema.Schema{ Description: "The resource `variable` represents a Prefect Cloud Variable. " + "Variables enable you to store and reuse non-sensitive information in your flows. ", - Version: 0, - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - // We cannot use a CustomType due to a conflict with PlanModifiers; see - // https://github.com/hashicorp/terraform-plugin-framework/issues/763 - // https://github.com/hashicorp/terraform-plugin-framework/issues/754 - Description: "Variable ID (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "created": schema.StringAttribute{ - Computed: true, - CustomType: customtypes.TimestampType{}, - Description: "Timestamp of when the resource was created (RFC3339)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), + Version: 1, + Attributes: VariableResourceSchemaAttributes, + } +} + +// UpgradeState adds upgraders to the VariableResource. +// This is needed when a resource schema change is made (eg. an attribute type). +// The key/index in the return object is the source version (eg. 0 -> current). +// The target version is the one defined in Schema.Version above +// https://developer.hashicorp.com/terraform/plugin/framework/resources/state-upgrade +func (r *VariableResource) UpgradeState(_ context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{ + // State upgrade implementation from prior (0) => current (Schema.Version) + 0: { + // PriorSchema allows the framework to populate the req.State argument + // for easier data handling when migrating existing state resources + // to a new schema. + PriorSchema: &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": VariableResourceSchemaAttributes["id"], + "created": VariableResourceSchemaAttributes["created"], + "updated": VariableResourceSchemaAttributes["updated"], + "account_id": VariableResourceSchemaAttributes["account_id"], + "workspace_id": VariableResourceSchemaAttributes["workspace_id"], + "name": VariableResourceSchemaAttributes["name"], + "value": schema.StringAttribute{ + Required: true, + }, + "tags": VariableResourceSchemaAttributes["tags"], }, }, - "updated": schema.StringAttribute{ - Computed: true, - CustomType: customtypes.TimestampType{}, - Description: "Timestamp of when the resource was updated (RFC3339)", - }, - "account_id": schema.StringAttribute{ - CustomType: customtypes.UUIDType{}, - Description: "Account ID (UUID), defaults to the account set in the provider", - Optional: true, - }, - "workspace_id": schema.StringAttribute{ - CustomType: customtypes.UUIDType{}, - Description: "Workspace ID (UUID), defaults to the workspace set in the provider", - Optional: true, - }, - "name": schema.StringAttribute{ - Description: "Name of the variable", - Required: true, - }, - "value": schema.DynamicAttribute{ - Description: "Value of the variable, supported Terraform value types: string, number, bool, tuple, object", - Required: true, - }, - "tags": schema.ListAttribute{ - Description: "Tags associated with the variable", - ElementType: types.StringType, - Optional: true, - Computed: true, - Default: listdefault.StaticValue(defaultEmptyTagList), + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var priorStateData VariableResourceModelV0 + + resp.Diagnostics.Append(req.State.Get(ctx, &priorStateData)...) + + if resp.Diagnostics.HasError() { + return + } + + // In order to update a prefect_variable resource in state + // that is tied to the old schema version, we need to copy + // the existing state into the new schema version. + upgradedStateData := VariableResourceModelV1{ + ID: priorStateData.ID, + Created: priorStateData.Created, + Updated: priorStateData.Updated, + AccountID: priorStateData.AccountID, + WorkspaceID: priorStateData.WorkspaceID, + Name: priorStateData.Name, + Tags: priorStateData.Tags, + } + + // This is the main upgrade operation between v0 => v1. + // Convert the "value" attribute's type from + // StringAttribute (v0) to a DynamicValue (v1) + // to prevent a Terraform error when deserializing the state + // from the old schema to the new one. + upgradedStateData.Value = types.DynamicValue(basetypes.NewStringValue(priorStateData.Value.ValueString())) + + resp.Diagnostics.Append(resp.State.Set(ctx, upgradedStateData)...) }, }, } @@ -138,7 +221,7 @@ func (r *VariableResource) Schema(_ context.Context, _ resource.SchemaRequest, r // copyVariableToModel maps an API response to a model that is saved in Terraform state. // A model can be a Terraform Plan, State, or Config object. -func copyVariableToModel(ctx context.Context, variable *api.Variable, tfModel *VariableResourceModel) diag.Diagnostics { +func copyVariableToModel(ctx context.Context, variable *api.Variable, tfModel *VariableResourceModelV1) diag.Diagnostics { tfModel.ID = types.StringValue(variable.ID.String()) tfModel.Created = customtypes.NewTimestampPointerValue(variable.Created) tfModel.Updated = customtypes.NewTimestampPointerValue(variable.Updated) @@ -156,7 +239,7 @@ func copyVariableToModel(ctx context.Context, variable *api.Variable, tfModel *V // getUnderlyingValue converts the 'value' attribute from a DynamicValue to // a native Go type that can be sent to the Prefect API. -func getUnderlyingValue(plan VariableResourceModel) (interface{}, diag.Diagnostics) { +func getUnderlyingValue(plan VariableResourceModelV1) (interface{}, diag.Diagnostics) { var diags diag.Diagnostics var value interface{} @@ -208,7 +291,7 @@ func getUnderlyingValue(plan VariableResourceModel) (interface{}, diag.Diagnosti // Create creates the resource and sets the initial Terraform state. func (r *VariableResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan VariableResourceModel + var plan VariableResourceModelV1 // Populate the model from resource configuration and emit diagnostics on error resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -259,7 +342,7 @@ func (r *VariableResource) Create(ctx context.Context, req resource.CreateReques // Read refreshes the Terraform state with the latest data. func (r *VariableResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state VariableResourceModel + var state VariableResourceModelV1 // Populate the model from state and emit diagnostics on error resp.Diagnostics.Append(req.State.Get(ctx, &state)...) @@ -319,7 +402,7 @@ func (r *VariableResource) Read(ctx context.Context, req resource.ReadRequest, r // Update updates the resource and sets the updated Terraform state on success. func (r *VariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan VariableResourceModel + var plan VariableResourceModelV1 resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { @@ -382,7 +465,7 @@ func (r *VariableResource) Update(ctx context.Context, req resource.UpdateReques // Delete deletes the resource and removes the Terraform state on success. func (r *VariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state VariableResourceModel + var state VariableResourceModelV1 resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() {