From 3ac88ce43d2ef26e40aafe86323f30aa477d53e2 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Fri, 12 Apr 2024 16:02:29 +0800 Subject: [PATCH] {wip} --- ...e_organization_run_task_global_settings.go | 114 +++++++ .../data_source_workspace_run_task.go | 16 +- .../data_source_workspace_run_task_test.go | 1 + internal/provider/helper_test.go | 11 + internal/provider/provider_next.go | 4 +- ...e_organization_run_task_global_settings.go | 298 ++++++++++++++++++ ...anization_run_task_global_settings_test.go | 240 ++++++++++++++ .../resource_tfe_workspace_run_task.go | 118 +++---- ...resource_tfe_workspace_run_task_schemas.go | 164 ++++++++++ 9 files changed, 886 insertions(+), 80 deletions(-) create mode 100644 internal/provider/data_source_organization_run_task_global_settings.go create mode 100644 internal/provider/resource_tfe_organization_run_task_global_settings.go create mode 100644 internal/provider/resource_tfe_organization_run_task_global_settings_test.go create mode 100644 internal/provider/resource_tfe_workspace_run_task_schemas.go diff --git a/internal/provider/data_source_organization_run_task_global_settings.go b/internal/provider/data_source_organization_run_task_global_settings.go new file mode 100644 index 000000000..182194d68 --- /dev/null +++ b/internal/provider/data_source_organization_run_task_global_settings.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = &dataSourceOrganizationRunTask{} + _ datasource.DataSourceWithConfigure = &dataSourceOrganizationRunTask{} +) + +func NewOrganizationRunTaskGlobalSettingsDataSource() datasource.DataSource { + return &dataSourceOrganizationRunTaskGlobalSettings{} +} + +type dataSourceOrganizationRunTaskGlobalSettings struct { + config ConfiguredClient +} + +func (d *dataSourceOrganizationRunTaskGlobalSettings) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization_run_task_global_settings" +} + +func (d *dataSourceOrganizationRunTaskGlobalSettings) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: "Whether the run task will be applied globally", + Optional: true, + }, + "enforcement_level": schema.StringAttribute{ + Description: "The enforcement level of the global task.", + Optional: true, + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "Service-generated identifier for the task settings", + }, + "stages": schema.ListAttribute{ + ElementType: types.StringType, + Description: "Which stages the task will run in.", + Optional: true, + }, + "task_id": schema.StringAttribute{ + Description: "The id of the run task.", + Required: true, + }, + }, + } +} + +func (d *dataSourceOrganizationRunTaskGlobalSettings) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(ConfiguredClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected tfe.ConfiguredClient, got %T. This is a bug in the tfe provider, so please report it on GitHub.", req.ProviderData), + ) + + return + } + d.config = client +} + +func (d *dataSourceOrganizationRunTaskGlobalSettings) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data modelDataTFEOrganizationRunTaskGlobalSettings + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + taskID := data.TaskID.ValueString() + + task, err := d.config.Client.RunTasks.Read(ctx, taskID) + if err != nil { + resp.Diagnostics.AddError("Error retrieving task", + fmt.Sprintf("Error retrieving task %s: %s", taskID, err.Error()), + ) + return + } + + if task == nil { + resp.Diagnostics.AddError("Error retrieving task", + fmt.Sprintf("Error retrieving task %s", taskID), + ) + return + } + + if task.Global == nil { + resp.Diagnostics.AddWarning("Error retrieving task", + fmt.Sprintf("The task %s exists however it does not support global run tasks.", taskID), + ) + return + } + + result := dataModelFromTFEOrganizationRunTaskGlobalSettings(*task) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, result)...) +} diff --git a/internal/provider/data_source_workspace_run_task.go b/internal/provider/data_source_workspace_run_task.go index b6f78e4ba..8fa35b275 100644 --- a/internal/provider/data_source_workspace_run_task.go +++ b/internal/provider/data_source_workspace_run_task.go @@ -10,25 +10,22 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" ) -// Ensure the implementation satisfies the expected interfaces. var ( _ datasource.DataSource = &dataSourceWorkspaceRunTask{} _ datasource.DataSourceWithConfigure = &dataSourceWorkspaceRunTask{} ) -// NewWorkspaceRunTaskDataSource is a helper function to simplify the provider implementation. func NewWorkspaceRunTaskDataSource() datasource.DataSource { return &dataSourceWorkspaceRunTask{} } -// dataSourceWorkspaceRunTask is the data source implementation. type dataSourceWorkspaceRunTask struct { config ConfiguredClient } -// Metadata returns the data source type name. func (d *dataSourceWorkspaceRunTask) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_workspace_run_task" } @@ -53,7 +50,13 @@ func (d *dataSourceWorkspaceRunTask) Schema(_ context.Context, _ datasource.Sche Computed: true, }, "stage": schema.StringAttribute{ - Description: "Which stage the task will run in.", + DeprecationMessage: "stage is deprecated, please use stages instead", + Description: "Which stage the task will run in.", + Computed: true, + }, + "stages": schema.ListAttribute{ + ElementType: types.StringType, + Description: "Which stages the task will run in.", Computed: true, }, }, @@ -78,9 +81,8 @@ func (d *dataSourceWorkspaceRunTask) Configure(_ context.Context, req datasource d.config = client } -// Read refreshes the Terraform state with the latest data. func (d *dataSourceWorkspaceRunTask) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data modelTFEWorkspaceRunTaskV0 + var data modelTFEWorkspaceRunTaskV1 // Read Terraform configuration data into the model resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) diff --git a/internal/provider/data_source_workspace_run_task_test.go b/internal/provider/data_source_workspace_run_task_test.go index f454e930c..7eaac6571 100644 --- a/internal/provider/data_source_workspace_run_task_test.go +++ b/internal/provider/data_source_workspace_run_task_test.go @@ -37,6 +37,7 @@ func TestAccTFEWorkspaceRunTaskDataSource_basic(t *testing.T) { resource.TestCheckResourceAttrSet("data.tfe_workspace_run_task.foobar", "id"), resource.TestCheckResourceAttrSet("data.tfe_workspace_run_task.foobar", "task_id"), resource.TestCheckResourceAttrSet("data.tfe_workspace_run_task.foobar", "workspace_id"), + resource.TestCheckResourceAttr("data.tfe_workspace_run_task.foobar", "stages.#", "1"), ), }, }, diff --git a/internal/provider/helper_test.go b/internal/provider/helper_test.go index 56a46e4f0..a27821a45 100644 --- a/internal/provider/helper_test.go +++ b/internal/provider/helper_test.go @@ -66,6 +66,17 @@ func createBusinessOrganization(t *testing.T, client *tfe.Client) (*tfe.Organiza return org, orgCleanup } +func createTrialOrganization(t *testing.T, client *tfe.Client) (*tfe.Organization, func()) { + org, orgCleanup := createOrganization(t, client, tfe.OrganizationCreateOptions{ + Name: tfe.String("tst-" + randomString(t)), + Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + }) + + newSubscriptionUpdater(org).WithTrialPlan().Update(t) + + return org, orgCleanup +} + func createOrganization(t *testing.T, client *tfe.Client, options tfe.OrganizationCreateOptions) (*tfe.Organization, func()) { ctx := context.Background() org, err := client.Organizations.Create(ctx, options) diff --git a/internal/provider/provider_next.go b/internal/provider/provider_next.go index 443e66823..81658eee1 100644 --- a/internal/provider/provider_next.go +++ b/internal/provider/provider_next.go @@ -123,12 +123,13 @@ func (p *frameworkProvider) Configure(ctx context.Context, req provider.Configur func (p *frameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + NewNoCodeModuleDataSource, NewOrganizationRunTaskDataSource, + NewOrganizationRunTaskGlobalSettingsDataSource, NewRegistryGPGKeyDataSource, NewRegistryGPGKeysDataSource, NewRegistryProviderDataSource, NewRegistryProvidersDataSource, - NewNoCodeModuleDataSource, NewSAMLSettingsDataSource, NewWorkspaceRunTaskDataSource, } @@ -136,6 +137,7 @@ func (p *frameworkProvider) DataSources(ctx context.Context) []func() datasource func (p *frameworkProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ + NewOrganizationRunTaskGlobalSettingsResource, NewOrganizationRunTaskResource, NewRegistryGPGKeyResource, NewRegistryProviderResource, diff --git a/internal/provider/resource_tfe_organization_run_task_global_settings.go b/internal/provider/resource_tfe_organization_run_task_global_settings.go new file mode 100644 index 000000000..2254e04ca --- /dev/null +++ b/internal/provider/resource_tfe_organization_run_task_global_settings.go @@ -0,0 +1,298 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "strings" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &resourceOrganizationRunTaskGlobalSettings{} +var _ resource.ResourceWithConfigure = &resourceOrganizationRunTaskGlobalSettings{} +var _ resource.ResourceWithImportState = &resourceOrganizationRunTaskGlobalSettings{} + +type modelDataTFEOrganizationRunTaskGlobalSettings struct { + Enabled types.Bool `tfsdk:"enabled"` + EnforcementLevel types.String `tfsdk:"enforcement_level"` + ID types.String `tfsdk:"id"` + Stages types.List `tfsdk:"stages"` + TaskID types.String `tfsdk:"task_id"` +} + +func dataModelFromTFEOrganizationRunTaskGlobalSettings(v tfe.RunTask) modelDataTFEOrganizationRunTaskGlobalSettings { + result := modelDataTFEOrganizationRunTaskGlobalSettings{ + Enabled: types.BoolNull(), + ID: types.StringValue(v.ID), + TaskID: types.StringValue(v.ID), + EnforcementLevel: types.StringNull(), + Stages: types.ListNull(types.StringType), + } + + if v.Global == nil { + return result + } + + result.Enabled = types.BoolValue(v.Global.Enabled) + result.EnforcementLevel = types.StringValue(string(v.Global.EnforcementLevel)) + if stages, err := types.ListValueFrom(ctx, types.StringType, v.Global.Stages); err == nil { + result.Stages = stages + } + + return result +} + +func NewOrganizationRunTaskGlobalSettingsResource() resource.Resource { + return &resourceOrganizationRunTaskGlobalSettings{} +} + +type resourceOrganizationRunTaskGlobalSettings struct { + config ConfiguredClient +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization_run_task_global_settings" +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 0, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Service-generated identifier for the task", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the run task will be applied globally", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "enforcement_level": schema.StringAttribute{ + Description: fmt.Sprintf("The enforcement level of the global task. Valid values are %s.", sentenceList( + workspaceRunTaskEnforcementLevels(), + "`", + "`", + "and", + )), + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(workspaceRunTaskEnforcementLevels()...), + }, + }, + "stages": schema.ListAttribute{ + ElementType: types.StringType, + Description: fmt.Sprintf("Which stages the task will run in. Valid values are %s.", sentenceList( + workspaceRunTaskStages(), + "`", + "`", + "and", + )), + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.UniqueValues(), + listvalidator.ValueStringsAre( + stringvalidator.OneOf(workspaceRunTaskStages()...), + ), + }, + Required: true, + }, + "task_id": schema.StringAttribute{ + Description: "The id of the run task.", + Required: true, + // When the task changes force a replace + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(ConfiguredClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected tfe.ConfiguredClient, got %T. This is a bug in the tfe provider, so please report it on GitHub.", req.ProviderData), + ) + + return + } + r.config = client +} + +func (r *resourceOrganizationRunTaskGlobalSettings) getRunTask(ctx context.Context, taskID string, diags *diag.Diagnostics) *tfe.RunTask { + tflog.Error(ctx, fmt.Sprintf("Reading organization run task %s", taskID)) + task, err := r.config.Client.RunTasks.Read(ctx, taskID) + + if err != nil || task == nil { + diags.AddError("Error reading Organization Run Task", "Could not read Organization Run Task, unexpected error: "+err.Error()) + return nil + } + + if task.Global == nil { + diags.AddError("Organization does not support global run tasks", + fmt.Sprintf("The task %s exists however it does not support global run tasks.", taskID), + ) + return nil + } + + return task +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state modelDataTFEOrganizationRunTaskGlobalSettings + + // Read Terraform current state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + taskID := state.TaskID.ValueString() + + task := r.getRunTask(ctx, taskID, &resp.Diagnostics) + if task == nil { + return + } + + result := dataModelFromTFEOrganizationRunTaskGlobalSettings(*task) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &result)...) +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.updateRunTask(ctx, &req.Plan, &resp.State, &resp.Diagnostics) +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.updateRunTask(ctx, &req.Plan, &resp.State, &resp.Diagnostics) +} + +func (r *resourceOrganizationRunTaskGlobalSettings) updateRunTask(ctx context.Context, tfPlan *tfsdk.Plan, tfState *tfsdk.State, diagnostics *diag.Diagnostics) { + var plan modelDataTFEOrganizationRunTaskGlobalSettings + + // Read Terraform planned changes into the model + diagnostics.Append(tfPlan.Get(ctx, &plan)...) + if diagnostics.HasError() { + return + } + + taskID := plan.TaskID.ValueString() + + task := r.getRunTask(ctx, taskID, diagnostics) + if task == nil { + return + } + + var stageStrings []types.String + if err := plan.Stages.ElementsAs(ctx, &stageStrings, false); err != nil && err.HasError() { + diagnostics.Append(err...) + return + } + + stages := make([]tfe.Stage, len(stageStrings)) + for idx, s := range stageStrings { + stages[idx] = tfe.Stage(s.ValueString()) + } + + options := tfe.RunTaskUpdateOptions{ + Global: &tfe.GlobalRunTaskOptions{ + Enabled: plan.Enabled.ValueBoolPointer(), + Stages: &stages, + EnforcementLevel: (*tfe.TaskEnforcementLevel)(plan.EnforcementLevel.ValueStringPointer()), + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Update task %s global settings", taskID)) + task, err := r.config.Client.RunTasks.Update(ctx, taskID, options) + if err != nil || task == nil { + diagnostics.AddError("Unable to update organization task", err.Error()) + return + } + result := dataModelFromTFEOrganizationRunTaskGlobalSettings(*task) + + diagnostics.Append(tfState.Set(ctx, &result)...) +} + +func (r *resourceOrganizationRunTaskGlobalSettings) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state modelDataTFEOrganizationRunTaskGlobalSettings + + // Read Terraform planned changes into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + taskID := state.TaskID.ValueString() + + e := false + options := tfe.RunTaskUpdateOptions{ + Global: &tfe.GlobalRunTaskOptions{ + Enabled: &e, + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Disabling task %s global settings", taskID)) + task, err := r.config.Client.RunTasks.Update(ctx, taskID, options) + if err != nil || task == nil { + resp.Diagnostics.AddError("Unable to update organization task", err.Error()) + return + } + // Resource is implicitly deleted from resp.State if diagnostics have no errors. +} + +func (r *resourceOrganizationRunTaskGlobalSettings) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + s := strings.SplitN(req.ID, "/", 2) + if len(s) != 2 { + resp.Diagnostics.AddError( + "Error importing organization run task global settings", + fmt.Sprintf("Invalid task input format: %s (expected /)", req.ID), + ) + return + } + + taskName := s[1] + orgName := s[0] + + if task, err := fetchOrganizationRunTask(taskName, orgName, r.config.Client); err != nil { + resp.Diagnostics.AddError( + "Error importing organization run task", + err.Error(), + ) + } else if task == nil { + resp.Diagnostics.AddError( + "Error importing organization run task", + "Task does not exist or does not support global settings", + ) + } else { + // We can never import the HMACkey (Write-only) so assume it's the default (empty) + result := dataModelFromTFEOrganizationRunTaskGlobalSettings(*task) + resp.Diagnostics.Append(resp.State.Set(ctx, &result)...) + } +} diff --git a/internal/provider/resource_tfe_organization_run_task_global_settings_test.go b/internal/provider/resource_tfe_organization_run_task_global_settings_test.go new file mode 100644 index 000000000..aa7e7a374 --- /dev/null +++ b/internal/provider/resource_tfe_organization_run_task_global_settings_test.go @@ -0,0 +1,240 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "math/rand" + "regexp" + "testing" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccTFEOrganizationRunTaskGlobalSettings_validateSchemaAttributeUrl(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + // enforcement_level + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_parameters("", `["pre_plan"]`), + ExpectError: regexp.MustCompile(`Attribute enforcement_level value must be one of: \[.*\]`), + }, + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_parameters("bad name", `["pre_plan"]`), + ExpectError: regexp.MustCompile(`Attribute enforcement_level value must be one of: \[.*\]`), + }, + // stages + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_parameters(string(tfe.Mandatory), `[]`), + ExpectError: regexp.MustCompile(`Attribute stages list must contain at least 1 elements.*`), + }, + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_parameters(string(tfe.Mandatory), `["pre_plan","BADWOLF","post_plan"]`), + ExpectError: regexp.MustCompile(`Attribute stages\[1\] value must be.*`), + }, + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_parameters(string(tfe.Mandatory), `["pre_plan","pre_plan","pre_plan"]`), + ExpectError: regexp.MustCompile(`Error: Duplicate List Value`), + }, + }, + }) +} + +func TestAccTFEOrganizationRunTaskGlobalSettings_create(t *testing.T) { + skipUnlessRunTasksDefined(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFEOrganizationRunTaskDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_basic(org.Name, rInt, runTasksURL(), runTasksHMACKey()), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEOrganizationRunTaskGlobalEnabled("tfe_organization_run_task.foobar", true), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "enabled", "true"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "enforcement_level", "mandatory"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "stages.#", "1"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "stages.0", "post_plan"), + ), + }, + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_update(org.Name, rInt, runTasksURL(), runTasksHMACKey()), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEOrganizationRunTaskGlobalEnabled("tfe_organization_run_task.foobar", false), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "enabled", "false"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "enforcement_level", "advisory"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "stages.#", "2"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "stages.0", "pre_plan"), + resource.TestCheckResourceAttr("tfe_organization_run_task_global_settings.sut", "stages.1", "post_plan"), + ), + }, + }, + }) +} + +func TestAccTFEOrganizationRunTaskGlobalSettings_createUnsupported(t *testing.T) { + skipUnlessRunTasksDefined(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createTrialOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFEOrganizationRunTaskDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_basic(org.Name, rInt, runTasksURL(), runTasksHMACKey()), + ExpectError: regexp.MustCompile(`Error: Organization does not support global run tasks`), + }, + }, + }) +} + +func TestAccTFEOrganizationRunTaskGlobalSettings_import(t *testing.T) { + skipUnlessRunTasksDefined(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFETeamAccessDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEOrganizationRunTaskGlobalSettings_basic(org.Name, rInt, runTasksURL(), runTasksHMACKey()), + }, + { + ResourceName: "tfe_organization_run_task_global_settings.sut", + ImportState: true, + ImportStateId: fmt.Sprintf("%s/foobar-task-%d", org.Name, rInt), + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckTFEOrganizationRunTaskGlobalEnabled(resourceName string, expectedEnabled bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + config := testAccProvider.Meta().(ConfiguredClient) + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + rt, err := config.Client.RunTasks.Read(ctx, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error reading Run Task: %w", err) + } + + if rt == nil { + return fmt.Errorf("Organization Run Task not found") + } + + if rt.Global == nil { + return fmt.Errorf("Organization Run Task exists but does not support global run tasks") + } + + if rt.Global.Enabled != expectedEnabled { + return fmt.Errorf("Task expected a global enabled value of %t, got %t", expectedEnabled, rt.Global.Enabled) + } + + return nil + } +} + +func testAccTFEOrganizationRunTaskGlobalSettings_basic(orgName string, rInt int, runTaskURL, runTaskHMACKey string) string { + return fmt.Sprintf(` +resource "tfe_organization_run_task" "foobar" { + organization = "%s" + url = "%s" + name = "foobar-task-%d" + enabled = false + hmac_key = "%s" +} + +resource "tfe_organization_run_task_global_settings" "sut" { + task_id = tfe_organization_run_task.foobar.id + + enabled = true + enforcement_level = "mandatory" + stages = ["post_plan"] +} +`, orgName, runTaskURL, rInt, runTaskHMACKey) +} + +func testAccTFEOrganizationRunTaskGlobalSettings_parameters(enforceLevel, stages string) string { + return fmt.Sprintf(` +resource "tfe_organization_run_task" "foobar" { + organization = "foo" + url = "http://somewhere.local" + name = "task_name" + enabled = false + hmac_key = "something" +} + +resource "tfe_organization_run_task_global_settings" "sut" { + task_id = tfe_organization_run_task.foobar.id + + enabled = true + enforcement_level = "%s" + stages = %s +} +`, enforceLevel, stages) +} + +func testAccTFEOrganizationRunTaskGlobalSettings_update(orgName string, rInt int, runTaskURL, runTaskHMACKey string) string { + return fmt.Sprintf(` + resource "tfe_organization_run_task" "foobar" { + organization = "%s" + url = "%s" + name = "foobar-task-%d-new" + enabled = true + hmac_key = "%s" + description = "a description" + } + + resource "tfe_organization_run_task_global_settings" "sut" { + task_id = tfe_organization_run_task.foobar.id + + enabled = false + enforcement_level = "advisory" + stages = ["pre_plan", "post_plan"] + } +`, orgName, runTaskURL, rInt, runTaskHMACKey) +} diff --git a/internal/provider/resource_tfe_workspace_run_task.go b/internal/provider/resource_tfe_workspace_run_task.go index 416510920..ba5e7a3bb 100644 --- a/internal/provider/resource_tfe_workspace_run_task.go +++ b/internal/provider/resource_tfe_workspace_run_task.go @@ -12,12 +12,6 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "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/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) @@ -67,22 +61,21 @@ func NewWorkspaceRunTaskResource() resource.Resource { return &resourceWorkspaceRunTask{} } -type modelTFEWorkspaceRunTaskV0 struct { - ID types.String `tfsdk:"id"` - WorkspaceID types.String `tfsdk:"workspace_id"` - TaskID types.String `tfsdk:"task_id"` - EnforcementLevel types.String `tfsdk:"enforcement_level"` - Stage types.String `tfsdk:"stage"` -} - -func modelFromTFEWorkspaceRunTask(v *tfe.WorkspaceRunTask) modelTFEWorkspaceRunTaskV0 { - return modelTFEWorkspaceRunTaskV0{ +func modelFromTFEWorkspaceRunTask(v *tfe.WorkspaceRunTask) modelTFEWorkspaceRunTaskV1 { + result := modelTFEWorkspaceRunTaskV1{ ID: types.StringValue(v.ID), WorkspaceID: types.StringValue(v.Workspace.ID), TaskID: types.StringValue(v.RunTask.ID), EnforcementLevel: types.StringValue(string(v.EnforcementLevel)), Stage: types.StringValue(string(v.Stage)), + Stages: types.ListNull(types.StringType), } + + if stages, err := types.ListValueFrom(ctx, types.StringType, v.Stages); err == nil { + result.Stages = stages + } + + return result } func (r *resourceWorkspaceRunTask) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -107,62 +100,11 @@ func (r *resourceWorkspaceRunTask) Configure(ctx context.Context, req resource.C } func (r *resourceWorkspaceRunTask) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Version: 0, - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - Description: "Service-generated identifier for the workspace task", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "workspace_id": schema.StringAttribute{ - Description: "The id of the workspace to associate the Run task to.", - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "task_id": schema.StringAttribute{ - Description: "The id of the Run task to associate to the Workspace.", - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "enforcement_level": schema.StringAttribute{ - Description: fmt.Sprintf("The enforcement level of the task. Valid values are %s.", sentenceList( - workspaceRunTaskEnforcementLevels(), - "`", - "`", - "and", - )), - Required: true, - Validators: []validator.String{ - stringvalidator.OneOf(workspaceRunTaskEnforcementLevels()...), - }, - }, - "stage": schema.StringAttribute{ - Description: fmt.Sprintf("The stage to run the task in. Valid values are %s.", sentenceList( - workspaceRunTaskStages(), - "`", - "`", - "and", - )), - Optional: true, - Computed: true, - Default: stringdefault.StaticString(string(tfe.PostPlan)), - Validators: []validator.String{ - stringvalidator.OneOf(workspaceRunTaskStages()...), - }, - }, - }, - } + resp.Schema = resourceWorkspaceRunTaskSchemaV1 } func (r *resourceWorkspaceRunTask) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state modelTFEWorkspaceRunTaskV0 + var state modelTFEWorkspaceRunTaskV1 // Read Terraform current state into the model resp.Diagnostics.Append(req.State.Get(ctx, &state)...) @@ -187,7 +129,7 @@ func (r *resourceWorkspaceRunTask) Read(ctx context.Context, req resource.ReadRe } func (r *resourceWorkspaceRunTask) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan modelTFEWorkspaceRunTaskV0 + var plan modelTFEWorkspaceRunTaskV1 // Read Terraform planned changes into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -239,7 +181,7 @@ func (r *resourceWorkspaceRunTask) stringPointerToStagePointer(val *string) *tfe } func (r *resourceWorkspaceRunTask) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan modelTFEWorkspaceRunTaskV0 + var plan modelTFEWorkspaceRunTaskV1 // Read Terraform planned changes into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -272,7 +214,7 @@ func (r *resourceWorkspaceRunTask) Update(ctx context.Context, req resource.Upda } func (r *resourceWorkspaceRunTask) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state modelTFEWorkspaceRunTaskV0 + var state modelTFEWorkspaceRunTaskV1 diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -323,3 +265,35 @@ func (r *resourceWorkspaceRunTask) ImportState(ctx context.Context, req resource resp.Diagnostics.Append(resp.State.Set(ctx, &result)...) } } + +func (r *resourceWorkspaceRunTask) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{ + 0: { + PriorSchema: &resourceWorkspaceRunTaskSchemaV0, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var oldData modelTFEWorkspaceRunTaskV0 + diags := req.State.Get(ctx, &oldData) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + oldWorkspaceID := oldData.WorkspaceID.ValueString() + oldID := oldData.ID.ValueString() + + wstask, err := r.config.Client.WorkspaceRunTasks.Read(ctx, oldWorkspaceID, oldID) + if err != nil || wstask == nil { + resp.Diagnostics.AddError( + "Error reading workspace run task", + fmt.Sprintf("Couldn't read workspace run task %s while trying to upgrade state of tfe_workspace_run_task: %s", oldID, err.Error()), + ) + return + } + + newData := modelFromTFEWorkspaceRunTask(wstask) + diags = resp.State.Set(ctx, newData) + resp.Diagnostics.Append(diags...) + }, + }, + } +} diff --git a/internal/provider/resource_tfe_workspace_run_task_schemas.go b/internal/provider/resource_tfe_workspace_run_task_schemas.go new file mode 100644 index 000000000..cce13c1f7 --- /dev/null +++ b/internal/provider/resource_tfe_workspace_run_task_schemas.go @@ -0,0 +1,164 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + + tfe "github.com/hashicorp/go-tfe" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type modelTFEWorkspaceRunTaskV0 struct { + ID types.String `tfsdk:"id"` + WorkspaceID types.String `tfsdk:"workspace_id"` + TaskID types.String `tfsdk:"task_id"` + EnforcementLevel types.String `tfsdk:"enforcement_level"` + Stage types.String `tfsdk:"stage"` +} + +var resourceWorkspaceRunTaskSchemaV0 = schema.Schema{ + Version: 0, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Service-generated identifier for the workspace task", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "workspace_id": schema.StringAttribute{ + Description: "The id of the workspace to associate the Run task to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "task_id": schema.StringAttribute{ + Description: "The id of the Run task to associate to the Workspace.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "enforcement_level": schema.StringAttribute{ + Description: fmt.Sprintf("The enforcement level of the task. Valid values are %s.", sentenceList( + workspaceRunTaskEnforcementLevels(), + "`", + "`", + "and", + )), + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(workspaceRunTaskEnforcementLevels()...), + }, + }, + "stage": schema.StringAttribute{ + Description: fmt.Sprintf("The stage to run the task in. Valid values are %s.", sentenceList( + workspaceRunTaskStages(), + "`", + "`", + "and", + )), + Optional: true, + Computed: true, + Default: stringdefault.StaticString(string(tfe.PostPlan)), + Validators: []validator.String{ + stringvalidator.OneOf(workspaceRunTaskStages()...), + }, + }, + }, +} + +type modelTFEWorkspaceRunTaskV1 struct { + ID types.String `tfsdk:"id"` + WorkspaceID types.String `tfsdk:"workspace_id"` + TaskID types.String `tfsdk:"task_id"` + EnforcementLevel types.String `tfsdk:"enforcement_level"` + Stage types.String `tfsdk:"stage"` + Stages types.List `tfsdk:"stages"` +} + +var resourceWorkspaceRunTaskSchemaV1 = schema.Schema{ + Version: 1, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Service-generated identifier for the workspace task", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "workspace_id": schema.StringAttribute{ + Description: "The id of the workspace to associate the Run task to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "task_id": schema.StringAttribute{ + Description: "The id of the Run task to associate to the Workspace.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "enforcement_level": schema.StringAttribute{ + Description: fmt.Sprintf("The enforcement level of the task. Valid values are %s.", sentenceList( + workspaceRunTaskEnforcementLevels(), + "`", + "`", + "and", + )), + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(workspaceRunTaskEnforcementLevels()...), + }, + }, + "stage": schema.StringAttribute{ + DeprecationMessage: "stage is deprecated, please use stages instead", + Description: fmt.Sprintf("The stage to run the task in. Valid values are %s.", sentenceList( + workspaceRunTaskStages(), + "`", + "`", + "and", + )), + Optional: true, + Computed: true, + Default: stringdefault.StaticString(string(tfe.PostPlan)), + Validators: []validator.String{ + stringvalidator.OneOf(workspaceRunTaskStages()...), + }, + }, + "stages": schema.ListAttribute{ + ElementType: types.StringType, + Description: fmt.Sprintf("The stages to run the task in. Valid values are %s.", sentenceList( + workspaceRunTaskStages(), + "`", + "`", + "and", + )), + // Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{ + // basetypes.NewStringValue(string(tfe.PostPlan)), + // })), + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.UniqueValues(), + listvalidator.ValueStringsAre( + stringvalidator.OneOf(workspaceRunTaskStages()...), + ), + }, + Optional: true, + Computed: true, + }, + }, +}