From e29f12799a848d501155a8ef0e81876c847c4b2f Mon Sep 17 00:00:00 2001 From: Manuel Wudka-Robles Date: Wed, 1 Nov 2023 12:58:55 -0700 Subject: [PATCH 1/5] Allow managing StateVersion backing data --- mocks/state_version_mocks.go | 42 ++++++++++++++++++++++++++++ state_version.go | 38 +++++++++++++++++++++++++ state_version_integration_test.go | 46 +++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/mocks/state_version_mocks.go b/mocks/state_version_mocks.go index ce9b04c75..c764fb61e 100644 --- a/mocks/state_version_mocks.go +++ b/mocks/state_version_mocks.go @@ -95,6 +95,20 @@ func (mr *MockStateVersionsMockRecorder) ListOutputs(ctx, svID, options interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOutputs", reflect.TypeOf((*MockStateVersions)(nil).ListOutputs), ctx, svID, options) } +// PermanentlyDeleteBackingData mocks base method. +func (m *MockStateVersions) PermanentlyDeleteBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentlyDeleteBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PermanentlyDeleteBackingData indicates an expected call of PermanentlyDeleteBackingData. +func (mr *MockStateVersionsMockRecorder) PermanentlyDeleteBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentlyDeleteBackingData", reflect.TypeOf((*MockStateVersions)(nil).PermanentlyDeleteBackingData), ctx, svID) +} + // Read mocks base method. func (m *MockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { m.ctrl.T.Helper() @@ -155,6 +169,34 @@ func (mr *MockStateVersionsMockRecorder) ReadWithOptions(ctx, svID, options inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadWithOptions", reflect.TypeOf((*MockStateVersions)(nil).ReadWithOptions), ctx, svID, options) } +// RestoreBackingData mocks base method. +func (m *MockStateVersions) RestoreBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RestoreBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RestoreBackingData indicates an expected call of RestoreBackingData. +func (mr *MockStateVersionsMockRecorder) RestoreBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestoreBackingData", reflect.TypeOf((*MockStateVersions)(nil).RestoreBackingData), ctx, svID) +} + +// SoftDeleteBackingData mocks base method. +func (m *MockStateVersions) SoftDeleteBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SoftDeleteBackingData indicates an expected call of SoftDeleteBackingData. +func (mr *MockStateVersionsMockRecorder) SoftDeleteBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteBackingData", reflect.TypeOf((*MockStateVersions)(nil).SoftDeleteBackingData), ctx, svID) +} + // Upload mocks base method. func (m *MockStateVersions) Upload(ctx context.Context, workspaceID string, options tfe.StateVersionUploadOptions) (*tfe.StateVersion, error) { m.ctrl.T.Helper() diff --git a/state_version.go b/state_version.go index d376b4212..3397250e8 100644 --- a/state_version.go +++ b/state_version.go @@ -62,6 +62,18 @@ type StateVersions interface { // process outputs asynchronously. When consuming outputs or other async StateVersion fields, be sure to // wait for ResourcesProcessed to become `true` before assuming they are empty. ListOutputs(ctx context.Context, svID string, options *StateVersionOutputsListOptions) (*StateVersionOutputsList, error) + + // SoftDeleteBackingData soft deletes the state version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + SoftDeleteBackingData(ctx context.Context, svID string) error + + // RestoreBackingData restores a soft deleted state version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + RestoreBackingData(ctx context.Context, svID string) error + + // PermanentlyDeleteBackingData permanently deletes a soft deleted state version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + PermanentlyDeleteBackingData(ctx context.Context, svID string) error } // stateVersions implements StateVersions. @@ -398,6 +410,32 @@ func (s *stateVersions) ListOutputs(ctx context.Context, svID string, options *S return sv, nil } +func (s *stateVersions) SoftDeleteBackingData(ctx context.Context, svID string) error { + return s.manageBackingData(ctx, svID, "soft_delete_backing_data") +} + +func (s *stateVersions) RestoreBackingData(ctx context.Context, svID string) error { + return s.manageBackingData(ctx, svID, "restore_backing_data") +} + +func (s *stateVersions) PermanentlyDeleteBackingData(ctx context.Context, svID string) error { + return s.manageBackingData(ctx, svID, "permanently_delete_backing_data") +} + +func (s *stateVersions) manageBackingData(ctx context.Context, svID, action string) error { + if !validStringID(&svID) { + return ErrInvalidStateVerID + } + + u := fmt.Sprintf("state-versions/%s/actions/%s", svID, action) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + // check that StateVersionListOptions fields had valid values func (o *StateVersionListOptions) valid() error { if o == nil { diff --git a/state_version_integration_test.go b/state_version_integration_test.go index 406c3f4be..fd0c85bdf 100644 --- a/state_version_integration_test.go +++ b/state_version_integration_test.go @@ -634,3 +634,49 @@ func TestStateVersionOutputs(t *testing.T) { assert.Error(t, err) }) } + +func TestStateVersions_ManageBackingData(t *testing.T) { + skipUnlessEnterprise(t) + + client := testClient(t) + ctx := context.Background() + + workspace, workspaceCleanup := createWorkspace(t, client, nil) + t.Cleanup(workspaceCleanup) + + nonCurrentStateVersion, svTestCleanup := createStateVersion(t, client, 0, workspace) + t.Cleanup(svTestCleanup) + + _, svTestCleanup = createStateVersion(t, client, 0, workspace) + t.Cleanup(svTestCleanup) + + t.Run("soft delete backing data", func(t *testing.T) { + err := client.StateVersions.SoftDeleteBackingData(ctx, nonCurrentStateVersion.ID) + require.NoError(t, err) + + _, err = client.StateVersions.Download(ctx, nonCurrentStateVersion.DownloadURL) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("restore backing data", func(t *testing.T) { + err := client.StateVersions.RestoreBackingData(ctx, nonCurrentStateVersion.ID) + require.NoError(t, err) + + _, err = client.StateVersions.Download(ctx, nonCurrentStateVersion.DownloadURL) + require.NoError(t, err) + }) + + t.Run("permanently delete backing data", func(t *testing.T) { + err := client.StateVersions.SoftDeleteBackingData(ctx, nonCurrentStateVersion.ID) + require.NoError(t, err) + + err = client.StateVersions.PermanentlyDeleteBackingData(ctx, nonCurrentStateVersion.ID) + require.NoError(t, err) + + err = client.StateVersions.RestoreBackingData(ctx, nonCurrentStateVersion.ID) + require.ErrorContainsf(t, err, "transition not allowed", "Restore backing data should fail") + + _, err = client.StateVersions.Download(ctx, nonCurrentStateVersion.DownloadURL) + assert.Equal(t, ErrResourceNotFound, err) + }) +} From 5b35a25703ca427b06e89faabb01ca49d0ea13b3 Mon Sep 17 00:00:00 2001 From: Manuel Wudka-Robles Date: Wed, 1 Nov 2023 13:13:22 -0700 Subject: [PATCH 2/5] Allow managing ConfigurationVersion backing data --- configuration_version.go | 38 +++++++++++++++++++ configuration_version_integration_test.go | 46 +++++++++++++++++++++++ mocks/configuration_version_mocks.go | 42 +++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/configuration_version.go b/configuration_version.go index d2e1f5c74..b83926ae7 100644 --- a/configuration_version.go +++ b/configuration_version.go @@ -55,6 +55,18 @@ type ConfigurationVersions interface { // Download a configuration version. Only configuration versions in the uploaded state may be downloaded. Download(ctx context.Context, cvID string) ([]byte, error) + + // SoftDeleteBackingData soft deletes the configuration version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + SoftDeleteBackingData(ctx context.Context, svID string) error + + // RestoreBackingData restores a soft deleted configuration version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + RestoreBackingData(ctx context.Context, svID string) error + + // PermanentlyDeleteBackingData permanently deletes a soft deleted configuration version's backing data + // **Note: This functionality is only available in Terraform Enterprise.** + PermanentlyDeleteBackingData(ctx context.Context, svID string) error } // configurationVersions implements ConfigurationVersions. @@ -356,3 +368,29 @@ func (s *configurationVersions) Download(ctx context.Context, cvID string) ([]by return buf.Bytes(), nil } + +func (s *configurationVersions) SoftDeleteBackingData(ctx context.Context, cvID string) error { + return s.manageBackingData(ctx, cvID, "soft_delete_backing_data") +} + +func (s *configurationVersions) RestoreBackingData(ctx context.Context, cvID string) error { + return s.manageBackingData(ctx, cvID, "restore_backing_data") +} + +func (s *configurationVersions) PermanentlyDeleteBackingData(ctx context.Context, cvID string) error { + return s.manageBackingData(ctx, cvID, "permanently_delete_backing_data") +} + +func (s *configurationVersions) manageBackingData(ctx context.Context, cvID, action string) error { + if !validStringID(&cvID) { + return ErrInvalidConfigVersionID + } + + u := fmt.Sprintf("configuration-versions/%s/actions/%s", cvID, action) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/configuration_version_integration_test.go b/configuration_version_integration_test.go index f52dfefb2..ea517c640 100644 --- a/configuration_version_integration_test.go +++ b/configuration_version_integration_test.go @@ -472,3 +472,49 @@ func TestConfigurationVersions_Unmarshal(t *testing.T) { assert.Equal(t, cv.Provisional, true) assert.Equal(t, cv.Speculative, true) } + +func TestConfigurationVersions_ManageBackingData(t *testing.T) { + skipUnlessEnterprise(t) + + client := testClient(t) + ctx := context.Background() + + workspace, workspaceCleanup := createWorkspace(t, client, nil) + t.Cleanup(workspaceCleanup) + + nonCurrentCv, uploadedCvCleanup := createUploadedConfigurationVersion(t, client, workspace) + defer uploadedCvCleanup() + + _, uploadedCvCleanup = createUploadedConfigurationVersion(t, client, workspace) + defer uploadedCvCleanup() + + t.Run("soft delete backing data", func(t *testing.T) { + err := client.ConfigurationVersions.SoftDeleteBackingData(ctx, nonCurrentCv.ID) + require.NoError(t, err) + + _, err = client.ConfigurationVersions.Download(ctx, nonCurrentCv.ID) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("restore backing data", func(t *testing.T) { + err := client.ConfigurationVersions.RestoreBackingData(ctx, nonCurrentCv.ID) + require.NoError(t, err) + + _, err = client.ConfigurationVersions.Download(ctx, nonCurrentCv.ID) + require.NoError(t, err) + }) + + t.Run("permanently delete backing data", func(t *testing.T) { + err := client.ConfigurationVersions.SoftDeleteBackingData(ctx, nonCurrentCv.ID) + require.NoError(t, err) + + err = client.ConfigurationVersions.PermanentlyDeleteBackingData(ctx, nonCurrentCv.ID) + require.NoError(t, err) + + err = client.ConfigurationVersions.RestoreBackingData(ctx, nonCurrentCv.ID) + require.ErrorContainsf(t, err, "transition not allowed", "Restore backing data should fail") + + _, err = client.ConfigurationVersions.Download(ctx, nonCurrentCv.ID) + assert.Equal(t, ErrResourceNotFound, err) + }) +} diff --git a/mocks/configuration_version_mocks.go b/mocks/configuration_version_mocks.go index f6b6640bd..1b296958b 100644 --- a/mocks/configuration_version_mocks.go +++ b/mocks/configuration_version_mocks.go @@ -110,6 +110,20 @@ func (mr *MockConfigurationVersionsMockRecorder) List(ctx, workspaceID, options return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockConfigurationVersions)(nil).List), ctx, workspaceID, options) } +// PermanentlyDeleteBackingData mocks base method. +func (m *MockConfigurationVersions) PermanentlyDeleteBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentlyDeleteBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// PermanentlyDeleteBackingData indicates an expected call of PermanentlyDeleteBackingData. +func (mr *MockConfigurationVersionsMockRecorder) PermanentlyDeleteBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentlyDeleteBackingData", reflect.TypeOf((*MockConfigurationVersions)(nil).PermanentlyDeleteBackingData), ctx, svID) +} + // Read mocks base method. func (m *MockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { m.ctrl.T.Helper() @@ -140,6 +154,34 @@ func (mr *MockConfigurationVersionsMockRecorder) ReadWithOptions(ctx, cvID, opti return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadWithOptions", reflect.TypeOf((*MockConfigurationVersions)(nil).ReadWithOptions), ctx, cvID, options) } +// RestoreBackingData mocks base method. +func (m *MockConfigurationVersions) RestoreBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RestoreBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RestoreBackingData indicates an expected call of RestoreBackingData. +func (mr *MockConfigurationVersionsMockRecorder) RestoreBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestoreBackingData", reflect.TypeOf((*MockConfigurationVersions)(nil).RestoreBackingData), ctx, svID) +} + +// SoftDeleteBackingData mocks base method. +func (m *MockConfigurationVersions) SoftDeleteBackingData(ctx context.Context, svID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteBackingData", ctx, svID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SoftDeleteBackingData indicates an expected call of SoftDeleteBackingData. +func (mr *MockConfigurationVersionsMockRecorder) SoftDeleteBackingData(ctx, svID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteBackingData", reflect.TypeOf((*MockConfigurationVersions)(nil).SoftDeleteBackingData), ctx, svID) +} + // Upload mocks base method. func (m *MockConfigurationVersions) Upload(ctx context.Context, url, path string) error { m.ctrl.T.Helper() From 854ff74df9e3acebcc3073143d77463c6c3561b9 Mon Sep 17 00:00:00 2001 From: Manuel Wudka-Robles Date: Wed, 1 Nov 2023 13:44:33 -0700 Subject: [PATCH 3/5] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e778dae66..d94b30c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features * Removed BETA labels for StateVersion Upload method, ConfigurationVersion `provisional` field, and `save-plan` runs by @brandonc [#800](https://github.com/hashicorp/go-tfe/pull/800) +* Allow soft deleting, restoring, and permanently deleting StateVersion and ConfigurationVersion backing data by @mwudka [#801](https://github.com/hashicorp/go-tfe/pull/801) # v.1.38.0 From 70dde471090202b041613ebdbb25f5a99f2422d4 Mon Sep 17 00:00:00 2001 From: Manuel Wudka-Robles Date: Wed, 1 Nov 2023 13:44:54 -0700 Subject: [PATCH 4/5] Add example of managing backing data --- examples/backing_data/main.go | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 examples/backing_data/main.go diff --git a/examples/backing_data/main.go b/examples/backing_data/main.go new file mode 100644 index 000000000..cfe1db88f --- /dev/null +++ b/examples/backing_data/main.go @@ -0,0 +1,72 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "context" + "flag" + "fmt" + tfe "github.com/hashicorp/go-tfe" + "log" + "strings" +) + +func main() { + action := flag.String("action", "", "Action (soft-delete|restore|permanently-delete") + externalId := flag.String("external-id", "", "External ID of StateVersion or ConfigurationVersion") + + flag.Parse() + + if action == nil || *action == "" { + log.Fatal("No Action provided") + } + + if externalId == nil || *externalId == "" { + log.Fatal("No external ID provided") + } + + ctx := context.Background() + client, err := tfe.NewClient(&tfe.Config{ + RetryServerErrors: true, + }) + if err != nil { + log.Fatal(err) + } + + err = performAction(ctx, client, *action, *externalId) + if err != nil { + log.Fatalf("Error performing action: %v", err) + } +} + +func performAction(ctx context.Context, client *tfe.Client, action string, id string) error { + externalIdParts := strings.Split(id, "-") + switch externalIdParts[0] { + case "cv": + switch action { + case "soft-delete": + return client.ConfigurationVersions.SoftDeleteBackingData(ctx, id) + case "restore": + return client.ConfigurationVersions.RestoreBackingData(ctx, id) + case "permanently-delete": + return client.ConfigurationVersions.PermanentlyDeleteBackingData(ctx, id) + default: + return fmt.Errorf("unsupported action: %s", action) + } + case "sv": + switch action { + case "soft-delete": + return client.StateVersions.SoftDeleteBackingData(ctx, id) + case "restore": + return client.StateVersions.RestoreBackingData(ctx, id) + case "permanently-delete": + return client.StateVersions.PermanentlyDeleteBackingData(ctx, id) + default: + return fmt.Errorf("unsupported action: %s", action) + } + default: + return fmt.Errorf("unsupported external ID: %s", id) + } + return nil +} From f4b7207cd8f204bfb1d710c82dc7f186b9de7dd6 Mon Sep 17 00:00:00 2001 From: Taylor Brooks Date: Mon, 6 Nov 2023 07:51:05 -0800 Subject: [PATCH 5/5] support organization default execution mode (#762) * add support for org-level default agent pools and execution mode * Apply suggestions from code review Co-authored-by: Nick Fagerlund * clarify setting overwrites in comments * update changelog * fmt * document setting overwrite fields * Update workspace.go Co-authored-by: Nick Fagerlund --------- Co-authored-by: Nick Fagerlund --- CHANGELOG.md | 6 ++ helper_test.go | 24 +++++ organization.go | 13 ++- organization_integration_test.go | 66 +++++++++++++- workspace.go | 113 ++++++++++++++++-------- workspace_integration_test.go | 147 +++++++++++++++++++++++++++++++ 6 files changed, 328 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e778dae66..10e843638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # UNRELEASED + + + +## Features +* New WorkspaceSettingOverwritesOptions field for allowing workspaces to defer some settings to a default from their organization or project by @SwiftEngineer [#762](https://github.com/hashicorp/go-tfe/pull/762) +* Added support for setting a default execution mode and agent pool at the organization level by @SwiftEngineer [#762](https://github.com/hashicorp/go-tfe/pull/762) ## Features * Removed BETA labels for StateVersion Upload method, ConfigurationVersion `provisional` field, and `save-plan` runs by @brandonc [#800](https://github.com/hashicorp/go-tfe/pull/800) diff --git a/helper_test.go b/helper_test.go index b666786ed..30dccd38b 100644 --- a/helper_test.go +++ b/helper_test.go @@ -965,6 +965,30 @@ func createOrganizationWithOptions(t *testing.T, client *Client, options Organiz } } +func createOrganizationWithDefaultAgentPool(t *testing.T, client *Client) (*Organization, func()) { + ctx := context.Background() + org, orgCleanup := createOrganizationWithOptions(t, client, OrganizationCreateOptions{ + Name: String("tst-" + randomString(t)), + Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), + CostEstimationEnabled: Bool(true), + }) + + agentPool, _ := createAgentPool(t, client, org) + + org, err := client.Organizations.Update(ctx, org.Name, OrganizationUpdateOptions{ + DefaultExecutionMode: String("agent"), + DefaultAgentPool: agentPool, + }) + + if err != nil { + t.Fatal(err) + } + + return org, func() { + // delete the org + orgCleanup() + } +} func createOrganizationMembership(t *testing.T, client *Client, org *Organization) (*OrganizationMembership, func()) { var orgCleanup func() diff --git a/organization.go b/organization.go index 80f8683a5..f298fac5f 100644 --- a/organization.go +++ b/organization.go @@ -74,6 +74,7 @@ type Organization struct { CollaboratorAuthPolicy AuthPolicyType `jsonapi:"attr,collaborator-auth-policy"` CostEstimationEnabled bool `jsonapi:"attr,cost-estimation-enabled"` CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + DefaultExecutionMode string `jsonapi:"attr,default-execution-mode"` Email string `jsonapi:"attr,email"` ExternalID string `jsonapi:"attr,external-id"` OwnersTeamSAMLRoleID string `jsonapi:"attr,owners-team-saml-role-id"` @@ -90,7 +91,8 @@ type Organization struct { AllowForceDeleteWorkspaces bool `jsonapi:"attr,allow-force-delete-workspaces"` // Relations - DefaultProject *Project `jsonapi:"relation,default-project"` + DefaultProject *Project `jsonapi:"relation,default-project"` + DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool"` } // OrganizationIncludeOpt represents the available options for include query params. @@ -198,6 +200,9 @@ type OrganizationCreateOptions struct { // Optional: AllowForceDeleteWorkspaces toggles behavior of allowing workspace admins to delete workspaces with resources under management. AllowForceDeleteWorkspaces *bool `jsonapi:"attr,allow-force-delete-workspaces,omitempty"` + + // Optional: DefaultExecutionMode the default execution mode for workspaces + DefaultExecutionMode *string `jsonapi:"attr,default-execution-mode,omitempty"` } // OrganizationUpdateOptions represents the options for updating an organization. @@ -237,6 +242,12 @@ type OrganizationUpdateOptions struct { // Optional: AllowForceDeleteWorkspaces toggles behavior of allowing workspace admins to delete workspaces with resources under management. AllowForceDeleteWorkspaces *bool `jsonapi:"attr,allow-force-delete-workspaces,omitempty"` + + // Optional: DefaultExecutionMode the default execution mode for workspaces + DefaultExecutionMode *string `jsonapi:"attr,default-execution-mode,omitempty"` + + // Optional: DefaultAgentPoolId default agent pool for workspaces, requires DefaultExecutionMode to be set to `agent` + DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool,omitempty"` } // ReadRunQueueOptions represents the options for showing the queue. diff --git a/organization_integration_test.go b/organization_integration_test.go index d99736815..e2c9e7f1a 100644 --- a/organization_integration_test.go +++ b/organization_integration_test.go @@ -122,6 +122,8 @@ func TestOrganizationsCreate(t *testing.T) { assert.Equal(t, *options.Name, org.Name) assert.Equal(t, *options.Email, org.Email) + assert.Equal(t, "remote", org.DefaultExecutionMode) + assert.Nil(t, org.DefaultAgentPool) }) t.Run("when no email is provided", func(t *testing.T) { @@ -195,6 +197,20 @@ func TestOrganizationsRead(t *testing.T) { require.NotNil(t, org.DefaultProject) assert.NotNil(t, org.DefaultProject.Name) }) + + t.Run("with default execution mode of 'agent'", func(t *testing.T) { + orgAgentTest, orgAgentTestCleanup := createOrganizationWithDefaultAgentPool(t, client) + org, err := client.Organizations.Read(ctx, orgAgentTest.Name) + + t.Cleanup(orgAgentTestCleanup) + require.NoError(t, err) + + t.Run("execution mode and agent pool are properly decoded", func(t *testing.T) { + assert.Equal(t, "agent", org.DefaultExecutionMode) + assert.NotNil(t, org.DefaultAgentPool) + assert.Equal(t, org.DefaultAgentPool.ID, orgAgentTest.DefaultAgentPool.ID) + }) + }) } func TestOrganizationsUpdate(t *testing.T) { @@ -221,10 +237,11 @@ func TestOrganizationsUpdate(t *testing.T) { orgTest, orgTestCleanup := createOrganization(t, client) options := OrganizationUpdateOptions{ - Name: String(randomString(t)), - Email: String(randomString(t) + "@tfe.local"), - SessionTimeout: Int(3600), - SessionRemember: Int(3600), + Name: String(randomString(t)), + Email: String(randomString(t) + "@tfe.local"), + SessionTimeout: Int(3600), + SessionRemember: Int(3600), + DefaultExecutionMode: String("local"), } org, err := client.Organizations.Update(ctx, orgTest.Name, options) @@ -254,6 +271,7 @@ func TestOrganizationsUpdate(t *testing.T) { assert.Equal(t, *options.Email, item.Email) assert.Equal(t, *options.SessionTimeout, item.SessionTimeout) assert.Equal(t, *options.SessionRemember, item.SessionRemember) + assert.Equal(t, *options.DefaultExecutionMode, item.DefaultExecutionMode) } }) @@ -263,6 +281,20 @@ func TestOrganizationsUpdate(t *testing.T) { assert.EqualError(t, err, ErrInvalidOrg.Error()) }) + t.Run("with agent pool provided, but remote execution mode", func(t *testing.T) { + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + org, err := client.Organizations.Update(ctx, orgTest.Name, OrganizationUpdateOptions{ + DefaultAgentPool: pool, + }) + assert.Nil(t, org) + assert.ErrorContains(t, err, "Default agent pool must not be specified unless using 'agent' execution mode") + }) + t.Run("when only updating a subset of fields", func(t *testing.T) { orgTest, orgTestCleanup := createOrganization(t, client) t.Cleanup(orgTestCleanup) @@ -272,6 +304,32 @@ func TestOrganizationsUpdate(t *testing.T) { assert.Equal(t, orgTest.Name, org.Name) assert.Equal(t, orgTest.Email, org.Email) }) + + t.Run("with different default execution modes", func(t *testing.T) { + // this helper creates an organization and then updates it to use a default agent pool, so it implicitly asserts + // that the organization's execution mode can be updated from 'remote' -> 'agent' + org, orgAgentTestCleanup := createOrganizationWithDefaultAgentPool(t, client) + assert.Equal(t, "agent", org.DefaultExecutionMode) + assert.NotNil(t, org.DefaultAgentPool) + + // assert that organization's execution mode can be updated from 'agent' -> 'remote' + org, err := client.Organizations.Update(ctx, org.Name, OrganizationUpdateOptions{ + DefaultExecutionMode: String("remote"), + }) + require.NoError(t, err) + assert.Equal(t, "remote", org.DefaultExecutionMode) + assert.Nil(t, org.DefaultAgentPool) + + // assert that organization's execution mode can be updated from 'remote' -> 'local' + org, err = client.Organizations.Update(ctx, org.Name, OrganizationUpdateOptions{ + DefaultExecutionMode: String("local"), + }) + require.NoError(t, err) + assert.Equal(t, "local", org.DefaultExecutionMode) + assert.Nil(t, org.DefaultAgentPool) + + t.Cleanup(orgAgentTestCleanup) + }) } func TestOrganizationsDelete(t *testing.T) { diff --git a/workspace.go b/workspace.go index bc85d2a44..bf812328e 100644 --- a/workspace.go +++ b/workspace.go @@ -116,42 +116,43 @@ type WorkspaceList struct { // Workspace represents a Terraform Enterprise workspace. type Workspace struct { - ID string `jsonapi:"primary,workspaces"` - Actions *WorkspaceActions `jsonapi:"attr,actions"` - AgentPoolID string `jsonapi:"attr,agent-pool-id"` - AllowDestroyPlan bool `jsonapi:"attr,allow-destroy-plan"` - AssessmentsEnabled bool `jsonapi:"attr,assessments-enabled"` - AutoApply bool `jsonapi:"attr,auto-apply"` - CanQueueDestroyPlan bool `jsonapi:"attr,can-queue-destroy-plan"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - Description string `jsonapi:"attr,description"` - Environment string `jsonapi:"attr,environment"` - ExecutionMode string `jsonapi:"attr,execution-mode"` - FileTriggersEnabled bool `jsonapi:"attr,file-triggers-enabled"` - GlobalRemoteState bool `jsonapi:"attr,global-remote-state"` - Locked bool `jsonapi:"attr,locked"` - MigrationEnvironment string `jsonapi:"attr,migration-environment"` - Name string `jsonapi:"attr,name"` - Operations bool `jsonapi:"attr,operations"` - Permissions *WorkspacePermissions `jsonapi:"attr,permissions"` - QueueAllRuns bool `jsonapi:"attr,queue-all-runs"` - SpeculativeEnabled bool `jsonapi:"attr,speculative-enabled"` - SourceName string `jsonapi:"attr,source-name"` - SourceURL string `jsonapi:"attr,source-url"` - StructuredRunOutputEnabled bool `jsonapi:"attr,structured-run-output-enabled"` - TerraformVersion string `jsonapi:"attr,terraform-version"` - TriggerPrefixes []string `jsonapi:"attr,trigger-prefixes"` - TriggerPatterns []string `jsonapi:"attr,trigger-patterns"` - VCSRepo *VCSRepo `jsonapi:"attr,vcs-repo"` - WorkingDirectory string `jsonapi:"attr,working-directory"` - UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` - ResourceCount int `jsonapi:"attr,resource-count"` - ApplyDurationAverage time.Duration `jsonapi:"attr,apply-duration-average"` - PlanDurationAverage time.Duration `jsonapi:"attr,plan-duration-average"` - PolicyCheckFailures int `jsonapi:"attr,policy-check-failures"` - RunFailures int `jsonapi:"attr,run-failures"` - RunsCount int `jsonapi:"attr,workspace-kpis-runs-count"` - TagNames []string `jsonapi:"attr,tag-names"` + ID string `jsonapi:"primary,workspaces"` + Actions *WorkspaceActions `jsonapi:"attr,actions"` + AgentPoolID string `jsonapi:"attr,agent-pool-id"` + AllowDestroyPlan bool `jsonapi:"attr,allow-destroy-plan"` + AssessmentsEnabled bool `jsonapi:"attr,assessments-enabled"` + AutoApply bool `jsonapi:"attr,auto-apply"` + CanQueueDestroyPlan bool `jsonapi:"attr,can-queue-destroy-plan"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + Description string `jsonapi:"attr,description"` + Environment string `jsonapi:"attr,environment"` + ExecutionMode string `jsonapi:"attr,execution-mode"` + FileTriggersEnabled bool `jsonapi:"attr,file-triggers-enabled"` + GlobalRemoteState bool `jsonapi:"attr,global-remote-state"` + Locked bool `jsonapi:"attr,locked"` + MigrationEnvironment string `jsonapi:"attr,migration-environment"` + Name string `jsonapi:"attr,name"` + Operations bool `jsonapi:"attr,operations"` + Permissions *WorkspacePermissions `jsonapi:"attr,permissions"` + QueueAllRuns bool `jsonapi:"attr,queue-all-runs"` + SpeculativeEnabled bool `jsonapi:"attr,speculative-enabled"` + SourceName string `jsonapi:"attr,source-name"` + SourceURL string `jsonapi:"attr,source-url"` + StructuredRunOutputEnabled bool `jsonapi:"attr,structured-run-output-enabled"` + TerraformVersion string `jsonapi:"attr,terraform-version"` + TriggerPrefixes []string `jsonapi:"attr,trigger-prefixes"` + TriggerPatterns []string `jsonapi:"attr,trigger-patterns"` + VCSRepo *VCSRepo `jsonapi:"attr,vcs-repo"` + WorkingDirectory string `jsonapi:"attr,working-directory"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` + ResourceCount int `jsonapi:"attr,resource-count"` + ApplyDurationAverage time.Duration `jsonapi:"attr,apply-duration-average"` + PlanDurationAverage time.Duration `jsonapi:"attr,plan-duration-average"` + PolicyCheckFailures int `jsonapi:"attr,policy-check-failures"` + RunFailures int `jsonapi:"attr,run-failures"` + RunsCount int `jsonapi:"attr,workspace-kpis-runs-count"` + TagNames []string `jsonapi:"attr,tag-names"` + SettingOverwrites *WorkspaceSettingOverwrites `jsonapi:"attr,setting-overwrites"` // Relations AgentPool *AgentPool `jsonapi:"relation,agent-pool"` @@ -203,6 +204,13 @@ type VCSRepo struct { WebhookURL string `jsonapi:"attr,webhook-url"` } +// Note: the fields of this struct are bool pointers instead of bool values, in order to simplify support for +// future TFE versions that support *some but not all* of the inherited defaults that go-tfe knows about. +type WorkspaceSettingOverwrites struct { + ExecutionMode *bool `jsonapi:"attr,execution-mode"` + AgentPool *bool `jsonapi:"attr,agent-pool"` +} + // WorkspaceActions represents the workspace actions. type WorkspaceActions struct { IsDestroyable bool `jsonapi:"attr,is-destroyable"` @@ -380,6 +388,19 @@ type WorkspaceCreateOptions struct { // exist, it is created and added to the workspace. Tags []*Tag `jsonapi:"relation,tags,omitempty"` + // Optional: Struct of booleans, which indicate whether the workspace + // specifies its own values for various settings. If you mark a setting as + // `false` in this struct, it will clear the workspace's existing value for + // that setting and defer to the default value that its project or + // organization provides. + // + // In general, it's not necessary to mark a setting as `true` in this + // struct; if you provide a literal value for a setting, Terraform Cloud will + // automatically update its overwrites field to `true`. If you do choose to + // manually mark a setting as overwritten, you must provide a value for that + // setting at the same time. + SettingOverwrites *WorkspaceSettingOverwritesOptions `jsonapi:"attr,setting-overwrites,omitempty"` + // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace. Project *Project `jsonapi:"relation,project,omitempty"` @@ -396,6 +417,13 @@ type VCSRepoOptions struct { GHAInstallationID *string `json:"github-app-installation-id,omitempty"` } +type WorkspaceSettingOverwritesOptions struct { + // If false, the workspace will defer to its organization or project's DefaultExecutionMode value. + ExecutionMode *bool `json:"execution-mode,omitempty"` + // If false, the workspace will defer to its organization or project's DefaultAgentPool value. + AgentPool *bool `json:"agent-pool,omitempty"` +} + // WorkspaceUpdateOptions represents the options for updating a workspace. type WorkspaceUpdateOptions struct { // Type is a public field utilized by JSON:API to @@ -488,6 +516,19 @@ type WorkspaceUpdateOptions struct { // repository. WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"` + // Optional: Struct of booleans, which indicate whether the workspace + // specifies its own values for various settings. If you mark a setting as + // `false` in this struct, it will clear the workspace's existing value for + // that setting and defer to the default value that its project or + // organization provides. + // + // In general, it's not necessary to mark a setting as `true` in this + // struct; if you provide a literal value for a setting, Terraform Cloud will + // automatically update its overwrites field to `true`. If you do choose to + // manually mark a setting as overwritten, you must provide a value for that + // setting at the same time. + SettingOverwrites *WorkspaceSettingOverwritesOptions `jsonapi:"attr,setting-overwrites,omitempty"` + // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace Project *Project `jsonapi:"relation,project,omitempty"` diff --git a/workspace_integration_test.go b/workspace_integration_test.go index c1cf827d8..20ba7feea 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -621,6 +621,32 @@ func TestWorkspacesCreate(t *testing.T) { assert.Equal(t, err, ErrRequiredAgentPoolID) }) + t.Run("when no execution mode is specified, in an organization with local as default execution mode", func(t *testing.T) { + // Remove the below organization creation and use the one from the outer scope once the feature flag is removed + orgTest, orgTestCleanup := createOrganizationWithOptions(t, client, OrganizationCreateOptions{ + Name: String("tst-" + randomString(t)[0:20] + "-ff-on"), + Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), + DefaultExecutionMode: String("local"), + }) + t.Cleanup(orgTestCleanup) + + options := WorkspaceCreateOptions{ + Name: String(fmt.Sprintf("foo-%s", randomString(t))), + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(false), + }, + } + + _, err := client.Workspaces.Create(ctx, orgTest.Name, options) + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.Workspaces.Read(ctx, orgTest.Name, *options.Name) + require.NoError(t, err) + + assert.Equal(t, "local", refreshed.ExecutionMode) + }) + t.Run("when an error is returned from the API", func(t *testing.T) { w, err := client.Workspaces.Create(ctx, "bar", WorkspaceCreateOptions{ Name: String(fmt.Sprintf("bar-%s", randomString(t))), @@ -692,6 +718,51 @@ func TestWorkspacesCreate(t *testing.T) { require.NoError(t, err) assert.Equal(t, options.TriggerPatterns, w.TriggerPatterns) }) + + t.Run("when organization has a default execution mode", func(t *testing.T) { + defaultExecutionOrgTest, defaultExecutionOrgTestCleanup := createOrganizationWithDefaultAgentPool(t, client) + t.Cleanup(defaultExecutionOrgTestCleanup) + + t.Run("with setting overwrites set to false, workspace inherits the default execution mode", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String(fmt.Sprintf("tst-agent-cody-banks-%s", randomString(t))), + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(false), + AgentPool: Bool(false), + }, + } + w, err := client.Workspaces.Create(ctx, defaultExecutionOrgTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "agent", w.ExecutionMode) + }) + + t.Run("with setting overwrites set to true, workspace ignores the default execution mode", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String(fmt.Sprintf("tst-agent-tony-tanks-%s", randomString(t))), + ExecutionMode: String("local"), + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(true), + AgentPool: Bool(true), + }, + } + w, err := client.Workspaces.Create(ctx, defaultExecutionOrgTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "local", w.ExecutionMode) + }) + + t.Run("when explicitly setting execution mode, workspace ignores the default execution mode", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String(fmt.Sprintf("tst-remotely-interesting-workspace-%s", randomString(t))), + ExecutionMode: String("remote"), + } + w, err := client.Workspaces.Create(ctx, defaultExecutionOrgTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "remote", w.ExecutionMode) + }) + }) } func TestWorkspacesRead(t *testing.T) { @@ -713,6 +784,7 @@ func TestWorkspacesRead(t *testing.T) { assert.NotEmpty(t, w.Actions) assert.Equal(t, orgTest.Name, w.Organization.Name) assert.NotEmpty(t, w.CreatedAt) + assert.NotEmpty(t, wTest.SettingOverwrites) }) t.Run("links are properly decoded", func(t *testing.T) { @@ -749,6 +821,33 @@ func TestWorkspacesRead(t *testing.T) { assert.Nil(t, w) assert.EqualError(t, err, ErrInvalidWorkspaceValue.Error()) }) + + t.Run("when workspace is inheriting the default execution mode", func(t *testing.T) { + defaultExecutionOrgTest, defaultExecutionOrgTestCleanup := createOrganizationWithDefaultAgentPool(t, client) + t.Cleanup(defaultExecutionOrgTestCleanup) + + options := WorkspaceCreateOptions{ + Name: String(fmt.Sprintf("tst-agent-cody-banks-%s", randomString(t))), + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(false), + AgentPool: Bool(false), + }, + } + + wDefaultTest, wDefaultTestCleanup := createWorkspaceWithOptions(t, client, defaultExecutionOrgTest, options) + t.Cleanup(wDefaultTestCleanup) + + t.Run("and workspace execution mode is default", func(t *testing.T) { + w, err := client.Workspaces.Read(ctx, defaultExecutionOrgTest.Name, wDefaultTest.Name) + assert.NoError(t, err) + assert.NotEmpty(t, w) + + assert.Equal(t, defaultExecutionOrgTest.DefaultExecutionMode, w.ExecutionMode) + assert.NotEmpty(t, w.SettingOverwrites) + assert.Equal(t, false, *w.SettingOverwrites.ExecutionMode) + assert.Equal(t, false, *w.SettingOverwrites.ExecutionMode) + }) + }) } func TestWorkspacesReadWithOptions(t *testing.T) { @@ -1441,6 +1540,54 @@ func TestWorkspacesUpdateByID(t *testing.T) { }) } +func TestWorkspacesUpdateWithDefaultExecutionMode(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + defaultExecutionOrgTest, defaultExecutionOrgTestCleanup := createOrganizationWithDefaultAgentPool(t, client) + t.Cleanup(defaultExecutionOrgTestCleanup) + + wTest, wCleanup := createWorkspace(t, client, defaultExecutionOrgTest) + t.Cleanup(wCleanup) + + t.Run("when explicitly setting execution mode, workspace ignores the default execution mode", func(t *testing.T) { + options := WorkspaceUpdateOptions{ + ExecutionMode: String("remote"), + } + w, err := client.Workspaces.Update(ctx, defaultExecutionOrgTest.Name, wTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "remote", w.ExecutionMode) + }) + + t.Run("with setting overwrites set to true, workspace ignores the default execution mode", func(t *testing.T) { + options := WorkspaceUpdateOptions{ + ExecutionMode: String("local"), + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(true), + AgentPool: Bool(true), + }, + } + w, err := client.Workspaces.Update(ctx, defaultExecutionOrgTest.Name, wTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "local", w.ExecutionMode) + }) + + t.Run("with setting overwrites set to false, workspace inherits the default execution mode", func(t *testing.T) { + options := WorkspaceUpdateOptions{ + SettingOverwrites: &WorkspaceSettingOverwritesOptions{ + ExecutionMode: Bool(false), + AgentPool: Bool(false), + }, + } + w, err := client.Workspaces.Update(ctx, defaultExecutionOrgTest.Name, wTest.Name, options) + + require.NoError(t, err) + assert.Equal(t, "agent", w.ExecutionMode) + }) +} + func TestWorkspacesDelete(t *testing.T) { client := testClient(t) ctx := context.Background()