diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1e19e08..8bbd8d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ * Add support for the Explorer API by @sebasslash [#1018](https://github.com/hashicorp/go-tfe/pull/1018) +## Enhancements + +* Add BETA support for adding custom project permission for variable sets `ProjectVariableSetsPermission` by @netramali [21879](https://github.com/hashicorp/atlas/pull/21879) + +# v1.73.1 + +## Bug fixes + +* Includes a critical security update in an upstream depdendency `hashicorp/go-slug` @NodyHub [#1025](https://github.com/hashicorp/go-tfe/pull/1025) +* Fix bug in BETA support for Linux arm64 agents, which is EXPERIMENTAL, SUBJECT TO CHANGE, and may not be available to all users @natalie-todd [#1022](https://github.com/hashicorp/go-tfe/pull/1022) + +# v1.73.0 + +## Enhancements + +* Add support for team notification configurations @notchairmk [#1016](https://github.com/hashicorp/go-tfe/pull/1016) + +# v1.72.0 + +## Enhancements + +* Add support for project level auto destroy settings @simonxmh [#1011](https://github.com/hashicorp/go-tfe/pull/1011) +* Add BETA support for Linux arm64 agents, which is EXPERIMENTAL, SUBJECT TO CHANGE, and may not be available to all users @natalie-todd [#1022](https://github.com/hashicorp/go-tfe/pull/1022) +* Adds support to delete all tag bindings on either a project or workspace by @sebasslash [#1023](https://github.com/hashicorp/go-tfe/pull/1023) + + # v1.71.0 ## Enhancements diff --git a/README.md b/README.md index 0c0deac6c..a102cc1c5 100644 --- a/README.md +++ b/README.md @@ -113,78 +113,6 @@ if err != nil { For complete usage of the API client, see the [full package docs](https://pkg.go.dev/github.com/hashicorp/go-tfe). -## API Coverage - -This API client covers most of the existing HCP Terraform API calls and is updated regularly to add new or missing endpoints. - -- [x] Account -- [x] Agents -- [x] Agent Pools -- [x] Agent Tokens -- [x] Applies -- [x] Audit Trails -- [x] Changelog -- [x] Comments -- [x] Configuration Versions -- [x] Cost Estimation -- [ ] Feature Sets -- [ ] Invoices -- [x] IP Ranges -- [x] Notification Configurations -- [x] OAuth Clients -- [x] OAuth Tokens -- [x] Organizations -- [x] Organization Memberships -- [x] Organization Tags -- [x] Organization Tokens -- [x] Plan Exports -- [x] Plans -- [x] Policies -- [x] Policy Checks -- [x] Policy Sets -- [x] Policy Set Parameters -- [x] Private Registry - - [x] Modules - - [x] No-Code Modules - - [x] Providers - - [x] Provider Versions and Platforms - - [x] GPG Keys -- [x] Projects -- [x] Runs -- [x] Run Events -- [x] Run Tasks -- [x] Run Tasks Integration -- [x] Run Triggers -- [x] SSH Keys -- [x] Stability Policy -- [x] State Versions -- [x] State Version Outputs -- [ ] Subscriptions -- [x] Team Access -- [x] Team Membership -- [x] Team Tokens -- [x] Teams -- [x] Test Runs -- [x] User Tokens -- [x] Users -- [x] Variable Sets -- [x] Variables -- [ ] VCS Events -- [x] Workspaces -- [x] Workspace-Specific Variables -- [x] Workspace Resources -- [x] Admin - - [x] Module Sharing - - [x] Organizations - - [x] Runs - - [x] Settings - - [x] Terraform Versions - - [x] OPA Versions - - [x] Sentinel Versions - - [x] Users - - [x] Workspaces - - ## Examples See the [examples directory](https://github.com/hashicorp/go-tfe/tree/main/examples). diff --git a/admin_terraform_version.go b/admin_terraform_version.go index 6ae0de9d9..01a69f84b 100644 --- a/admin_terraform_version.go +++ b/admin_terraform_version.go @@ -7,12 +7,19 @@ import ( "context" "fmt" "net/url" + "reflect" "time" ) // Compile-time proof of interface implementation. var _ AdminTerraformVersions = (*adminTerraformVersions)(nil) +const ( + linux = "linux" + amd64 = "amd64" + arm64 = "arm64" +) + // AdminTerraformVersions describes all the admin terraform versions related methods that // the Terraform Enterprise API supports. // Note that admin terraform versions are only available in Terraform Enterprise. @@ -55,6 +62,13 @@ type AdminTerraformVersion struct { CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` } +type ToolVersionArchitectureOptions struct { + URL string `json:"url"` + Sha string `json:"sha"` + OS string `json:"os"` + Arch string `json:"arch"` +} + // AdminTerraformVersionsListOptions represents the options for listing // terraform versions. type AdminTerraformVersionsListOptions struct { @@ -70,15 +84,16 @@ type AdminTerraformVersionsListOptions struct { // AdminTerraformVersionCreateOptions for creating a terraform version. // https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/terraform-versions#request-body type AdminTerraformVersionCreateOptions struct { - Type string `jsonapi:"primary,terraform-versions"` - Version *string `jsonapi:"attr,version"` // Required - URL *string `jsonapi:"attr,url"` // Required - Sha *string `jsonapi:"attr,sha"` // Required - Official *bool `jsonapi:"attr,official,omitempty"` - Deprecated *bool `jsonapi:"attr,deprecated,omitempty"` - DeprecatedReason *string `jsonapi:"attr,deprecated-reason,omitempty"` - Enabled *bool `jsonapi:"attr,enabled,omitempty"` - Beta *bool `jsonapi:"attr,beta,omitempty"` + Type string `jsonapi:"primary,terraform-versions"` + Version *string `jsonapi:"attr,version"` // Required + URL *string `jsonapi:"attr,url"` // Required + Sha *string `jsonapi:"attr,sha"` // Required + Official *bool `jsonapi:"attr,official,omitempty"` + Deprecated *bool `jsonapi:"attr,deprecated,omitempty"` + DeprecatedReason *string `jsonapi:"attr,deprecated-reason,omitempty"` + Enabled *bool `jsonapi:"attr,enabled,omitempty"` + Beta *bool `jsonapi:"attr,beta,omitempty"` + Archs []*ToolVersionArchitectureOptions `jsonapi:"attr,archs,omitempty"` } // AdminTerraformVersionUpdateOptions for updating terraform version. @@ -153,7 +168,6 @@ func (a *adminTerraformVersions) Create(ctx context.Context, options AdminTerraf if err != nil { return nil, err } - return tfv, nil } @@ -194,18 +208,25 @@ func (a *adminTerraformVersions) Delete(ctx context.Context, id string) error { } func (o AdminTerraformVersionCreateOptions) valid() error { - if (o == AdminTerraformVersionCreateOptions{}) { + if (reflect.DeepEqual(o, AdminTerraformVersionCreateOptions{})) { return ErrRequiredTFVerCreateOps } if !validString(o.Version) { return ErrRequiredVersion } - if !validString(o.URL) { - return ErrRequiredURL - } - if !validString(o.Sha) { - return ErrRequiredSha + if !o.validArch() && (!validString(o.URL) || !validString(o.Sha)) { + return ErrRequiredArchOrURLAndSha } - return nil } + +func (o AdminTerraformVersionCreateOptions) validArch() bool { + var valid bool + for _, a := range o.Archs { + valid = validString(&a.URL) && validString(&a.Sha) && a.OS == linux && (a.Arch == amd64 || a.Arch == arm64) + if valid { + break + } + } + return valid +} diff --git a/admin_terraform_version_integration_test.go b/admin_terraform_version_integration_test.go index bec375a5e..67898e256 100644 --- a/admin_terraform_version_integration_test.go +++ b/admin_terraform_version_integration_test.go @@ -100,11 +100,50 @@ func TestAdminTerraformVersions_CreateDelete(t *testing.T) { client := testClient(t) ctx := context.Background() - version := genSafeRandomTerraformVersion() - t.Run("with valid options", func(t *testing.T) { + t.Run("with valid options and archs", func(t *testing.T) { opts := AdminTerraformVersionCreateOptions{ - Version: String(version), + Version: String(genSafeRandomTerraformVersion()), + Deprecated: Bool(true), + DeprecatedReason: String("Test Reason"), + Official: Bool(false), + Enabled: Bool(false), + Beta: Bool(false), + Archs: []*ToolVersionArchitectureOptions{ + { + URL: "https://www.hashicorp.com", + Sha: *String(genSha(t)), + OS: linux, + Arch: amd64, + }, + { + URL: "https://www.hashicorp.com", + Sha: *String(genSha(t)), + OS: linux, + Arch: arm64, + }}, + } + tfv, err := client.Admin.TerraformVersions.Create(ctx, opts) + require.NoError(t, err) + + defer func() { + deleteErr := client.Admin.TerraformVersions.Delete(ctx, tfv.ID) + require.NoError(t, deleteErr) + }() + + assert.Equal(t, *opts.Version, tfv.Version) + assert.Equal(t, *opts.URL, tfv.URL) + assert.Equal(t, *opts.Sha, tfv.Sha) + assert.Equal(t, *opts.Official, tfv.Official) + assert.Equal(t, *opts.Deprecated, tfv.Deprecated) + assert.Equal(t, *opts.DeprecatedReason, *tfv.DeprecatedReason) + assert.Equal(t, *opts.Enabled, tfv.Enabled) + assert.Equal(t, *opts.Beta, tfv.Beta) + }) + + t.Run("with valid options, url, and sha", func(t *testing.T) { + opts := AdminTerraformVersionCreateOptions{ + Version: String(genSafeRandomTerraformVersion()), URL: String("https://www.hashicorp.com"), Sha: String(genSha(t)), Deprecated: Bool(true), @@ -170,6 +209,7 @@ func TestAdminTerraformVersions_ReadUpdate(t *testing.T) { t.Run("reads and updates", func(t *testing.T) { version := genSafeRandomTerraformVersion() + sha := String(genSha(t)) opts := AdminTerraformVersionCreateOptions{ Version: String(version), URL: String("https://www.hashicorp.com"), @@ -179,6 +219,12 @@ func TestAdminTerraformVersions_ReadUpdate(t *testing.T) { DeprecatedReason: String("Test Reason"), Enabled: Bool(false), Beta: Bool(false), + Archs: []*ToolVersionArchitectureOptions{{ + URL: "https://www.hashicorp.com", + Sha: *sha, + OS: linux, + Arch: amd64, + }}, } tfv, err := client.Admin.TerraformVersions.Create(ctx, opts) require.NoError(t, err) diff --git a/configuration_version_integration_test.go b/configuration_version_integration_test.go index ea517c640..2ded10a93 100644 --- a/configuration_version_integration_test.go +++ b/configuration_version_integration_test.go @@ -311,7 +311,6 @@ func TestConfigurationVersionsUploadTarGzip(t *testing.T) { packer, err := slug.NewPacker( slug.DereferenceSymlinks(), slug.ApplyTerraformIgnore(), - slug.AllowSymlinkTarget("/target/symlink/path/foo"), ) require.NoError(t, err) diff --git a/errors.go b/errors.go index e0ad266c6..e6475d802 100644 --- a/errors.go +++ b/errors.go @@ -278,6 +278,8 @@ var ( ErrRequiredURL = errors.New("url is required") + ErrRequiredArchOrURLAndSha = errors.New("valid arch or url and sha is required") + ErrRequiredAPIURL = errors.New("API URL is required") ErrRequiredHTTPURL = errors.New("HTTP URL is required") diff --git a/example_test.go b/example_test.go index 863e65c4d..9c49bd241 100644 --- a/example_test.go +++ b/example_test.go @@ -90,9 +90,8 @@ func ExampleConfigurationVersions_UploadTarGzip() { } packer, err := slug.NewPacker( - slug.DereferenceSymlinks(), // dereferences symlinks - slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore - slug.AllowSymlinkTarget("/some/path"), // allow certain symlink target paths + slug.DereferenceSymlinks(), // dereferences symlinks + slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore ) if err != nil { log.Fatal(err) @@ -131,9 +130,8 @@ func ExampleRegistryModules_UploadTarGzip() { } packer, err := slug.NewPacker( - slug.DereferenceSymlinks(), // dereferences symlinks - slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore - slug.AllowSymlinkTarget("/some/path"), // allow certain symlink target paths + slug.DereferenceSymlinks(), // dereferences symlinks + slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore ) if err != nil { log.Fatal(err) diff --git a/examples/configuration_versions/main.go b/examples/configuration_versions/main.go index 3aa3d08c9..65ba9731f 100644 --- a/examples/configuration_versions/main.go +++ b/examples/configuration_versions/main.go @@ -22,9 +22,8 @@ func main() { } packer, err := slug.NewPacker( - slug.DereferenceSymlinks(), // dereferences symlinks - slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore - slug.AllowSymlinkTarget("/some/path"), // allow certain symlink target paths + slug.DereferenceSymlinks(), // dereferences symlinks + slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore ) if err != nil { log.Fatal(err) diff --git a/examples/projects/main.go b/examples/projects/main.go new file mode 100644 index 000000000..b952f1bb5 --- /dev/null +++ b/examples/projects/main.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "context" + "log" + + tfe "github.com/hashicorp/go-tfe" + + "github.com/hashicorp/jsonapi" +) + +func main() { + config := &tfe.Config{ + Token: "insert-your-token-here", + RetryServerErrors: true, + } + + client, err := tfe.NewClient(config) + if err != nil { + log.Fatal(err) + } + + // Create a context + ctx := context.Background() + + // Create a new project + p, err := client.Projects.Create(ctx, "org-test", tfe.ProjectCreateOptions{ + Name: "my-app-tst", + }) + if err != nil { + log.Fatal(err) + } + + // Update the project auto destroy activity duration + p, err = client.Projects.Update(ctx, p.ID, tfe.ProjectUpdateOptions{ + AutoDestroyActivityDuration: jsonapi.NewNullableAttrWithValue("3d"), + }) + if err != nil { + log.Fatal(err) + } + + // Disable auto destroy + p, err = client.Projects.Update(ctx, p.ID, tfe.ProjectUpdateOptions{ + AutoDestroyActivityDuration: jsonapi.NewNullNullableAttr[string](), + }) + if err != nil { + log.Fatal(err) + } + + err = client.Projects.Delete(ctx, p.ID) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/registry_modules/main.go b/examples/registry_modules/main.go index 2965aba31..bc424ec6b 100644 --- a/examples/registry_modules/main.go +++ b/examples/registry_modules/main.go @@ -22,9 +22,8 @@ func main() { } packer, err := slug.NewPacker( - slug.DereferenceSymlinks(), // dereferences symlinks - slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore - slug.AllowSymlinkTarget("/some/path"), // allow certain symlink target paths + slug.DereferenceSymlinks(), // dereferences symlinks + slug.ApplyTerraformIgnore(), // ignores paths specified in .terraformignore ) if err != nil { log.Fatal(err) diff --git a/examples/workspaces/main.go b/examples/workspaces/main.go index 5d460acac..b324e1dba 100644 --- a/examples/workspaces/main.go +++ b/examples/workspaces/main.go @@ -27,8 +27,9 @@ func main() { // Create a new workspace w, err := client.Workspaces.Create(ctx, "org-name", tfe.WorkspaceCreateOptions{ - Name: tfe.String("my-app-tst"), - AutoDestroyAt: tfe.NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + Name: tfe.String("my-app-tst"), + AutoDestroyAt: tfe.NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + InheritsProjectAutoDestroy: tfe.Bool(false), }) if err != nil { log.Fatal(err) @@ -36,10 +37,11 @@ func main() { // Update the workspace w, err = client.Workspaces.Update(ctx, "org-name", w.Name, tfe.WorkspaceUpdateOptions{ - AutoApply: tfe.Bool(false), - TerraformVersion: tfe.String("0.11.1"), - WorkingDirectory: tfe.String("my-app/infra"), - AutoDestroyAt: tfe.NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + AutoApply: tfe.Bool(false), + TerraformVersion: tfe.String("0.11.1"), + WorkingDirectory: tfe.String("my-app/infra"), + AutoDestroyAt: tfe.NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + InheritsProjectAutoDestroy: tfe.Bool(false), }) if err != nil { log.Fatal(err) diff --git a/go.mod b/go.mod index 22ad6cda3..f22b6c716 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,10 @@ require ( github.com/google/go-querystring v1.1.0 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-retryablehttp v0.7.7 - github.com/hashicorp/go-slug v0.16.0 + github.com/hashicorp/go-slug v0.16.3 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/jsonapi v1.3.1 + github.com/hashicorp/jsonapi v1.3.2 github.com/stretchr/testify v1.9.0 go.uber.org/mock v0.4.0 golang.org/x/sync v0.8.0 diff --git a/go.sum b/go.sum index 1293b8565..24170f19d 100644 --- a/go.sum +++ b/go.sum @@ -10,14 +10,14 @@ github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/S github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-slug v0.16.0 h1:S/ko9fms1gf6305ktJNUKGxFmscZ+yWvAtsas0SYUyA= -github.com/hashicorp/go-slug v0.16.0/go.mod h1:THWVTAXwJEinbsp4/bBRcmbaO5EYNLTqxbG4tZ3gCYQ= +github.com/hashicorp/go-slug v0.16.3 h1:pe0PMwz2UWN1168QksdW/d7u057itB2gY568iF0E2Ns= +github.com/hashicorp/go-slug v0.16.3/go.mod h1:THWVTAXwJEinbsp4/bBRcmbaO5EYNLTqxbG4tZ3gCYQ= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/jsonapi v1.3.1 h1:GtPvnmcWgYwCuDGvYT5VZBHcUyFdq9lSyCzDjn1DdPo= -github.com/hashicorp/jsonapi v1.3.1/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= +github.com/hashicorp/jsonapi v1.3.2 h1:gP3fX2ZT7qXi+PbwieptzkspIohO2kCSiBUvUTBAbMs= +github.com/hashicorp/jsonapi v1.3.2/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/helper_test.go b/helper_test.go index 0450f316a..d19c5d095 100644 --- a/helper_test.go +++ b/helper_test.go @@ -615,6 +615,55 @@ func createNotificationConfiguration(t *testing.T, client *Client, w *Workspace, } } +func createTeamNotificationConfiguration(t *testing.T, client *Client, team *Team, options *NotificationConfigurationCreateOptions) (*NotificationConfiguration, func()) { + var tCleanup func() + + if team == nil { + team, tCleanup = createTeam(t, client, nil) + } + + // Team notification configurations do not actually require a run task, but we'll + // reuse this as a URL that returns a 200. + runTaskURL := os.Getenv("TFC_RUN_TASK_URL") + if runTaskURL == "" { + t.Error("You must set TFC_RUN_TASK_URL for run task related tests.") + } + + if options == nil { + options = &NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String(runTaskURL), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: team}, + } + } + + ctx := context.Background() + nc, err := client.NotificationConfigurations.Create( + ctx, + team.ID, + *options, + ) + if err != nil { + t.Fatal(err) + } + + return nc, func() { + if err := client.NotificationConfigurations.Delete(ctx, nc.ID); err != nil { + t.Errorf("Error destroying team notification configuration! WARNING: Dangling\n"+ + "resources may exist! The full error is shown below.\n\n"+ + "NotificationConfiguration: %s\nError: %s", nc.ID, err) + } + + if tCleanup != nil { + tCleanup() + } + } +} + func createPolicySetParameter(t *testing.T, client *Client, ps *PolicySet) (*PolicySetParameter, func()) { var psCleanup func() diff --git a/mocks/notification_configuration_mocks.go b/mocks/notification_configuration_mocks.go index c34212e83..5fb999a40 100644 --- a/mocks/notification_configuration_mocks.go +++ b/mocks/notification_configuration_mocks.go @@ -41,18 +41,18 @@ func (m *MockNotificationConfigurations) EXPECT() *MockNotificationConfiguration } // Create mocks base method. -func (m *MockNotificationConfigurations) Create(ctx context.Context, workspaceID string, options tfe.NotificationConfigurationCreateOptions) (*tfe.NotificationConfiguration, error) { +func (m *MockNotificationConfigurations) Create(ctx context.Context, subscribableID string, options tfe.NotificationConfigurationCreateOptions) (*tfe.NotificationConfiguration, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, workspaceID, options) + ret := m.ctrl.Call(m, "Create", ctx, subscribableID, options) ret0, _ := ret[0].(*tfe.NotificationConfiguration) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockNotificationConfigurationsMockRecorder) Create(ctx, workspaceID, options any) *gomock.Call { +func (mr *MockNotificationConfigurationsMockRecorder) Create(ctx, subscribableID, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockNotificationConfigurations)(nil).Create), ctx, workspaceID, options) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockNotificationConfigurations)(nil).Create), ctx, subscribableID, options) } // Delete mocks base method. @@ -70,18 +70,18 @@ func (mr *MockNotificationConfigurationsMockRecorder) Delete(ctx, notificationCo } // List mocks base method. -func (m *MockNotificationConfigurations) List(ctx context.Context, workspaceID string, options *tfe.NotificationConfigurationListOptions) (*tfe.NotificationConfigurationList, error) { +func (m *MockNotificationConfigurations) List(ctx context.Context, subscribableID string, options *tfe.NotificationConfigurationListOptions) (*tfe.NotificationConfigurationList, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", ctx, workspaceID, options) + ret := m.ctrl.Call(m, "List", ctx, subscribableID, options) ret0, _ := ret[0].(*tfe.NotificationConfigurationList) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockNotificationConfigurationsMockRecorder) List(ctx, workspaceID, options any) *gomock.Call { +func (mr *MockNotificationConfigurationsMockRecorder) List(ctx, subscribableID, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockNotificationConfigurations)(nil).List), ctx, workspaceID, options) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockNotificationConfigurations)(nil).List), ctx, subscribableID, options) } // Read mocks base method. diff --git a/mocks/project_mocks.go b/mocks/project_mocks.go index 405c8afd7..9f2ed5c8c 100644 --- a/mocks/project_mocks.go +++ b/mocks/project_mocks.go @@ -84,6 +84,20 @@ func (mr *MockProjectsMockRecorder) Delete(ctx, projectID any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProjects)(nil).Delete), ctx, projectID) } +// DeleteAllTagBindings mocks base method. +func (m *MockProjects) DeleteAllTagBindings(ctx context.Context, projectID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllTagBindings", ctx, projectID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllTagBindings indicates an expected call of DeleteAllTagBindings. +func (mr *MockProjectsMockRecorder) DeleteAllTagBindings(ctx, projectID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllTagBindings", reflect.TypeOf((*MockProjects)(nil).DeleteAllTagBindings), ctx, projectID) +} + // List mocks base method. func (m *MockProjects) List(ctx context.Context, organization string, options *tfe.ProjectListOptions) (*tfe.ProjectList, error) { m.ctrl.T.Helper() diff --git a/mocks/workspace_mocks.go b/mocks/workspace_mocks.go index f9d89571d..537ad370d 100644 --- a/mocks/workspace_mocks.go +++ b/mocks/workspace_mocks.go @@ -128,6 +128,20 @@ func (mr *MockWorkspacesMockRecorder) Delete(ctx, organization, workspace any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockWorkspaces)(nil).Delete), ctx, organization, workspace) } +// DeleteAllTagBindings mocks base method. +func (m *MockWorkspaces) DeleteAllTagBindings(ctx context.Context, workspaceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllTagBindings", ctx, workspaceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllTagBindings indicates an expected call of DeleteAllTagBindings. +func (mr *MockWorkspacesMockRecorder) DeleteAllTagBindings(ctx, workspaceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllTagBindings", reflect.TypeOf((*MockWorkspaces)(nil).DeleteAllTagBindings), ctx, workspaceID) +} + // DeleteByID mocks base method. func (m *MockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { m.ctrl.T.Helper() diff --git a/notification_configuration.go b/notification_configuration.go index aeac2f04e..ad6c8dfae 100644 --- a/notification_configuration.go +++ b/notification_configuration.go @@ -20,10 +20,10 @@ var _ NotificationConfigurations = (*notificationConfigurations)(nil) // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/notification-configurations type NotificationConfigurations interface { // List all the notification configurations within a workspace. - List(ctx context.Context, workspaceID string, options *NotificationConfigurationListOptions) (*NotificationConfigurationList, error) + List(ctx context.Context, subscribableID string, options *NotificationConfigurationListOptions) (*NotificationConfigurationList, error) // Create a new notification configuration with the given options. - Create(ctx context.Context, workspaceID string, options NotificationConfigurationCreateOptions) (*NotificationConfiguration, error) + Create(ctx context.Context, subscribableID string, options NotificationConfigurationCreateOptions) (*NotificationConfiguration, error) // Read a notification configuration by its ID. Read(ctx context.Context, notificationConfigurationID string) (*NotificationConfiguration, error) @@ -59,6 +59,7 @@ const ( NotificationTriggerAssessmentCheckFailed NotificationTriggerType = "assessment:check_failure" NotificationTriggerWorkspaceAutoDestroyReminder NotificationTriggerType = "workspace:auto_destroy_reminder" NotificationTriggerWorkspaceAutoDestroyRunResults NotificationTriggerType = "workspace:auto_destroy_run_results" + NotificationTriggerChangeRequestCreated NotificationTriggerType = "change_request:created" ) // NotificationDestinationType represents the destination type of the @@ -80,6 +81,14 @@ type NotificationConfigurationList struct { Items []*NotificationConfiguration } +// NotificationConfigurationSubscribableChoice is a choice type struct that represents the possible values +// within a polymorphic relation. If a value is available, exactly one field +// will be non-nil. +type NotificationConfigurationSubscribableChoice struct { + Team *Team + Workspace *Workspace +} + // NotificationConfiguration represents a Notification Configuration. type NotificationConfiguration struct { ID string `jsonapi:"primary,notification-configurations"` @@ -97,8 +106,11 @@ type NotificationConfiguration struct { EmailAddresses []string `jsonapi:"attr,email-addresses"` // Relations - Subscribable *Workspace `jsonapi:"relation,subscribable"` - EmailUsers []*User `jsonapi:"relation,users"` + // DEPRECATED. The subscribable field is polymorphic. Use NotificationConfigurationSubscribableChoice instead. + Subscribable *Workspace `jsonapi:"relation,subscribable,omitempty"` + SubscribableChoice *NotificationConfigurationSubscribableChoice `jsonapi:"polyrelation,subscribable"` + + EmailUsers []*User `jsonapi:"relation,users"` } // DeliveryResponse represents a notification configuration delivery response. @@ -115,6 +127,8 @@ type DeliveryResponse struct { // notification configurations. type NotificationConfigurationListOptions struct { ListOptions + + SubscribableChoice *NotificationConfigurationSubscribableChoice } // NotificationConfigurationCreateOptions represents the options for @@ -150,6 +164,9 @@ type NotificationConfigurationCreateOptions struct { // Optional: The list of users belonging to the organization that will receive notification emails. EmailUsers []*User `jsonapi:"relation,users,omitempty"` + + // Required: The workspace or team that the notification configuration is associated with. + SubscribableChoice *NotificationConfigurationSubscribableChoice `jsonapi:"polyrelation,subscribable,omitempty"` } // NotificationConfigurationUpdateOptions represents the options for @@ -185,12 +202,32 @@ type NotificationConfigurationUpdateOptions struct { } // List all the notification configurations associated with a workspace. -func (s *notificationConfigurations) List(ctx context.Context, workspaceID string, options *NotificationConfigurationListOptions) (*NotificationConfigurationList, error) { - if !validStringID(&workspaceID) { - return nil, ErrInvalidWorkspaceID +func (s *notificationConfigurations) List(ctx context.Context, subscribableID string, options *NotificationConfigurationListOptions) (*NotificationConfigurationList, error) { + var u string + if options == nil { + options = &NotificationConfigurationListOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Workspace: &Workspace{ID: subscribableID}, + }, + } + } else if options.SubscribableChoice == nil { + options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{ + Workspace: &Workspace{ID: subscribableID}, + } + } + + if options.SubscribableChoice.Team != nil { + if !validStringID(&subscribableID) { + return nil, ErrInvalidTeamID + } + u = fmt.Sprintf("teams/%s/notification-configurations", url.PathEscape(subscribableID)) + } else { + if !validStringID(&subscribableID) { + return nil, ErrInvalidWorkspaceID + } + u = fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(subscribableID)) } - u := fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(workspaceID)) req, err := s.client.NewRequest("GET", u, options) if err != nil { return nil, err @@ -202,30 +239,43 @@ func (s *notificationConfigurations) List(ctx context.Context, workspaceID strin return nil, err } + for i := range ncl.Items { + backfillDeprecatedSubscribable(ncl.Items[i]) + } + return ncl, nil } // Create a notification configuration with the given options. -func (s *notificationConfigurations) Create(ctx context.Context, workspaceID string, options NotificationConfigurationCreateOptions) (*NotificationConfiguration, error) { - if !validStringID(&workspaceID) { - return nil, ErrInvalidWorkspaceID +func (s *notificationConfigurations) Create(ctx context.Context, subscribableID string, options NotificationConfigurationCreateOptions) (*NotificationConfiguration, error) { + var u string + var subscribableChoice *NotificationConfigurationSubscribableChoice + if options.SubscribableChoice == nil || options.SubscribableChoice.Team == nil { + u = fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(subscribableID)) + options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Workspace: &Workspace{ID: subscribableID}} + } else { + u = fmt.Sprintf("teams/%s/notification-configurations", url.PathEscape(subscribableID)) + options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Team: &Team{ID: subscribableID}} } + if err := options.valid(); err != nil { return nil, err } - u := fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(workspaceID)) req, err := s.client.NewRequest("POST", u, &options) if err != nil { return nil, err } - nc := &NotificationConfiguration{} + nc := &NotificationConfiguration{SubscribableChoice: subscribableChoice} err = req.Do(ctx, nc) + if err != nil { return nil, err } + backfillDeprecatedSubscribable(nc) + return nc, nil } @@ -247,6 +297,8 @@ func (s *notificationConfigurations) Read(ctx context.Context, notificationConfi return nil, err } + backfillDeprecatedSubscribable(nc) + return nc, nil } @@ -272,6 +324,8 @@ func (s *notificationConfigurations) Update(ctx context.Context, notificationCon return nil, err } + backfillDeprecatedSubscribable(nc) + return nc, nil } @@ -314,6 +368,16 @@ func (s *notificationConfigurations) Verify(ctx context.Context, notificationCon } func (o NotificationConfigurationCreateOptions) valid() error { + if o.SubscribableChoice == nil || o.SubscribableChoice.Workspace != nil { + if !validStringID(&o.SubscribableChoice.Workspace.ID) { + return ErrInvalidWorkspaceID + } + } else { + if !validStringID(&o.SubscribableChoice.Team.ID) { + return ErrInvalidTeamID + } + } + if o.DestinationType == nil { return ErrRequiredDestinationType } @@ -350,6 +414,16 @@ func (o NotificationConfigurationUpdateOptions) valid() error { return nil } +func backfillDeprecatedSubscribable(notification *NotificationConfiguration) { + if notification.Subscribable != nil || notification.SubscribableChoice == nil { + return + } + + if notification.SubscribableChoice.Workspace != nil { + notification.Subscribable = notification.SubscribableChoice.Workspace + } +} + func validNotificationTriggerType(triggers []NotificationTriggerType) bool { for _, t := range triggers { switch t { @@ -363,6 +437,7 @@ func validNotificationTriggerType(triggers []NotificationTriggerType) bool { NotificationTriggerAssessmentFailed, NotificationTriggerWorkspaceAutoDestroyReminder, NotificationTriggerWorkspaceAutoDestroyRunResults, + NotificationTriggerChangeRequestCreated, NotificationTriggerAssessmentCheckFailed: continue default: diff --git a/notification_configuration_integration_test.go b/notification_configuration_integration_test.go index 927c08212..d81ede21d 100644 --- a/notification_configuration_integration_test.go +++ b/notification_configuration_integration_test.go @@ -34,13 +34,16 @@ func TestNotificationConfigurationList(t *testing.T) { assert.Contains(t, ncl.Items, ncTest1) assert.Contains(t, ncl.Items, ncTest2) - t.Skip("paging not supported yet in API") - assert.Equal(t, 1, ncl.CurrentPage) - assert.Equal(t, 2, ncl.TotalCount) + assert.Equal(t, 0, ncl.CurrentPage) + assert.Equal(t, 0, ncl.TotalCount) + + assert.NotNil(t, ncl.Items[0].Subscribable) + assert.NotEmpty(t, ncl.Items[0].Subscribable) + assert.NotNil(t, ncl.Items[0].SubscribableChoice.Workspace) + assert.NotEmpty(t, ncl.Items[0].SubscribableChoice.Workspace) }) t.Run("with list options", func(t *testing.T) { - t.Skip("paging not supported yet in API") // Request a page number which is out of range. The result should // be successful, but return no results if the paging options are // properly passed along. @@ -71,6 +74,55 @@ func TestNotificationConfigurationList(t *testing.T) { }) } +func TestNotificationConfigurationList_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + require.NotNil(t, tmTest) + + ncTest1, ncTestCleanup1 := createTeamNotificationConfiguration(t, client, tmTest, nil) + t.Cleanup(ncTestCleanup1) + ncTest2, ncTestCleanup2 := createTeamNotificationConfiguration(t, client, tmTest, nil) + t.Cleanup(ncTestCleanup2) + + t.Run("with a valid team", func(t *testing.T) { + ncl, err := client.NotificationConfigurations.List( + ctx, + tmTest.ID, + &NotificationConfigurationListOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Team: tmTest, + }, + }, + ) + require.NoError(t, err) + assert.Contains(t, ncl.Items, ncTest1) + assert.Contains(t, ncl.Items, ncTest2) + }) + + t.Run("without a valid team", func(t *testing.T) { + ncl, err := client.NotificationConfigurations.List( + ctx, + badIdentifier, + &NotificationConfigurationListOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Team: tmTest, + }, + }, + ) + assert.Nil(t, ncl) + assert.EqualError(t, err, ErrInvalidTeamID.Error()) + }) +} + func TestNotificationConfigurationCreate(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -255,6 +307,156 @@ func TestNotificationConfigurationsCreate_byType(t *testing.T) { } } +func TestNotificationConfigurationCreate_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + + // Create user to use when testing email destination type + orgMemberTest, orgMemberTestCleanup := createOrganizationMembership(t, client, orgTest) + t.Cleanup(orgMemberTestCleanup) + + // Add user to team + options := TeamMemberAddOptions{ + OrganizationMembershipIDs: []string{orgMemberTest.ID}, + } + err := client.TeamMembers.Add(ctx, tmTest.ID, options) + require.NoError(t, err) + + t.Run("with all required values", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + + require.NoError(t, err) + require.NotNil(t, nc) + }) + + t.Run("without a required value", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + + assert.Nil(t, nc) + assert.EqualError(t, err, ErrRequiredName.Error()) + }) + + t.Run("without a required value URL when destination type is generic", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a required value URL when destination type is slack", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeSlack), + Enabled: Bool(false), + Name: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a required value URL when destination type is MS Teams", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeMicrosoftTeams), + Enabled: Bool(false), + Name: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerChangeRequestCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a valid team", func(t *testing.T) { + nc, err := client.NotificationConfigurations.Create(ctx, badIdentifier, NotificationConfigurationCreateOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Team: tmTest, + }, + }) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidTeamID.Error()) + }) + + t.Run("with an invalid notification trigger", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{"the beacons of gondor are lit"}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidNotificationTrigger.Error()) + }) + + t.Run("with email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + EmailUsers: []*User{orgMemberTest.User}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + _, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + require.NoError(t, err) + }) + + t.Run("without email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + + _, err := client.NotificationConfigurations.Create(ctx, tmTest.ID, options) + require.NoError(t, err) + }) +} + func TestNotificationConfigurationRead(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -279,6 +481,149 @@ func TestNotificationConfigurationRead(t *testing.T) { }) } +func TestNotificationConfigurationRead_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + + ncTest, ncTestCleanup := createTeamNotificationConfiguration(t, client, tmTest, nil) + t.Cleanup(ncTestCleanup) + + t.Run("with a valid ID", func(t *testing.T) { + nc, err := client.NotificationConfigurations.Read(ctx, ncTest.ID) + require.NoError(t, err) + assert.Equal(t, ncTest.ID, nc.ID) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + _, err := client.NotificationConfigurations.Read(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Read(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + +func TestNotificationConfigurationUpdate_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + + ncTest, ncTestCleanup := createTeamNotificationConfiguration(t, client, tmTest, nil) + t.Cleanup(ncTestCleanup) + + // Create users to use when testing email destination type + orgMemberTest1, orgMemberTest1Cleanup := createOrganizationMembership(t, client, orgTest) + defer orgMemberTest1Cleanup() + orgMemberTest2, orgMemberTest2Cleanup := createOrganizationMembership(t, client, orgTest) + defer orgMemberTest2Cleanup() + + orgMemberTest1.User = &User{ID: orgMemberTest1.User.ID} + orgMemberTest2.User = &User{ID: orgMemberTest2.User.ID} + + // Add users to team + for _, orgMember := range []*OrganizationMembership{orgMemberTest1, orgMemberTest2} { + options := TeamMemberAddOptions{ + OrganizationMembershipIDs: []string{orgMember.ID}, + } + err := client.TeamMembers.Add(ctx, tmTest.ID, options) + require.NoError(t, err) + } + + options := &NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + EmailUsers: []*User{orgMemberTest1.User}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Team: tmTest}, + } + ncEmailTest, ncEmailTestCleanup := createTeamNotificationConfiguration(t, client, tmTest, options) + t.Cleanup(ncEmailTestCleanup) + + t.Run("with options", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + }) + + t.Run("with invalid notification trigger", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Triggers: []NotificationTriggerType{"fly you fools!"}, + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, options) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidNotificationTrigger.Error()) + }) + + t.Run("with email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + EmailUsers: []*User{orgMemberTest1.User, orgMemberTest2.User}, + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncEmailTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + assert.Contains(t, nc.EmailUsers, orgMemberTest1.User) + assert.Contains(t, nc.EmailUsers, orgMemberTest2.User) + }) + + t.Run("without email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncEmailTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + assert.Empty(t, nc.EmailUsers) + }) + + t.Run("without options", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, NotificationConfigurationUpdateOptions{}) + require.NoError(t, err) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, "nonexisting", NotificationConfigurationUpdateOptions{}) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, badIdentifier, NotificationConfigurationUpdateOptions{}) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + func TestNotificationConfigurationUpdate(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -404,6 +749,40 @@ func TestNotificationConfigurationDelete(t *testing.T) { }) } +func TestNotificationConfigurationDelete_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + + ncTest, _ := createTeamNotificationConfiguration(t, client, tmTest, nil) + + t.Run("with a valid ID", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, ncTest.ID) + require.NoError(t, err) + + _, err = client.NotificationConfigurations.Read(ctx, ncTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + func TestNotificationConfigurationVerify(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -426,3 +805,35 @@ func TestNotificationConfigurationVerify(t *testing.T) { assert.Equal(t, err, ErrInvalidNotificationConfigID) }) } + +func TestNotificationConfigurationVerify_forTeams(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + newSubscriptionUpdater(orgTest).WithPlusEntitlementPlan().Update(t) + + tmTest, tmTestCleanup := createTeam(t, client, orgTest) + t.Cleanup(tmTestCleanup) + + ncTest, ncTestCleanup := createTeamNotificationConfiguration(t, client, tmTest, nil) + t.Cleanup(ncTestCleanup) + + t.Run("with a valid ID", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, ncTest.ID) + require.NoError(t, err) + }) + + t.Run("when the notification configuration does not exists", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} diff --git a/project.go b/project.go index 89437a328..433549f3d 100644 --- a/project.go +++ b/project.go @@ -7,6 +7,8 @@ import ( "context" "fmt" "net/url" + + "github.com/hashicorp/jsonapi" ) // Compile-time proof of interface implementation. @@ -42,6 +44,9 @@ type Projects interface { // AddTagBindings adds or modifies the value of existing tag binding keys for a project. AddTagBindings(ctx context.Context, projectID string, options ProjectAddTagBindingsOptions) ([]*TagBinding, error) + + // DeleteAllTagBindings removes all existing tag bindings for a project. + DeleteAllTagBindings(ctx context.Context, projectID string) error } // projects implements Projects @@ -63,6 +68,8 @@ type Project struct { Description string `jsonapi:"attr,description"` + AutoDestroyActivityDuration jsonapi.NullableAttr[string] `jsonapi:"attr,auto-destroy-activity-duration,omitempty"` + // Relations Organization *Organization `jsonapi:"relation,organization"` } @@ -100,6 +107,11 @@ type ProjectCreateOptions struct { // Associated TagBindings of the project. TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` + + // Optional: For all workspaces in the project, the period of time to wait + // after workspace activity to trigger a destroy run. The format should roughly + // match a Go duration string limited to days and hours, e.g. "24h" or "1d". + AutoDestroyActivityDuration jsonapi.NullableAttr[string] `jsonapi:"attr,auto-destroy-activity-duration,omitempty"` } // ProjectUpdateOptions represents the options for updating a project @@ -119,6 +131,11 @@ type ProjectUpdateOptions struct { // Associated TagBindings of the project. Note that this will replace // all existing tag bindings. TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` + + // Optional: For all workspaces in the project, the period of time to wait + // after workspace activity to trigger a destroy run. The format should roughly + // match a Go duration string limited to days and hours, e.g. "24h" or "1d". + AutoDestroyActivityDuration jsonapi.NullableAttr[string] `jsonapi:"attr,auto-destroy-activity-duration,omitempty"` } // ProjectAddTagBindingsOptions represents the options for adding tag bindings @@ -312,6 +329,30 @@ func (s *projects) Delete(ctx context.Context, projectID string) error { return req.Do(ctx, nil) } +// Delete all tag bindings associated with a project. +func (s *projects) DeleteAllTagBindings(ctx context.Context, projectID string) error { + if !validStringID(&projectID) { + return ErrInvalidProjectID + } + + type aliasOpts struct { + Type string `jsonapi:"primary,projects"` + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings"` + } + + opts := &aliasOpts{ + TagBindings: []*TagBinding{}, + } + + u := fmt.Sprintf("projects/%s", url.PathEscape(projectID)) + req, err := s.client.NewRequest("PATCH", u, opts) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + func (o ProjectCreateOptions) valid() error { if !validString(&o.Name) { return ErrRequiredName diff --git a/projects_integration_test.go b/projects_integration_test.go index 962719282..b84a9e869 100644 --- a/projects_integration_test.go +++ b/projects_integration_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/hashicorp/jsonapi" ) func TestProjectsList(t *testing.T) { @@ -150,6 +152,8 @@ func TestProjectsCreate(t *testing.T) { orgTest, orgTestCleanup := createOrganization(t, client) defer orgTestCleanup() + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) + t.Run("with valid options", func(t *testing.T) { options := ProjectCreateOptions{ Name: "foo", @@ -193,6 +197,17 @@ func TestProjectsCreate(t *testing.T) { assert.Nil(t, w) assert.EqualError(t, err, ErrInvalidOrg.Error()) }) + + t.Run("when options has an invalid auto destroy activity duration", func(t *testing.T) { + skipUnlessBeta(t) + + w, err := client.Projects.Create(ctx, orgTest.Name, ProjectCreateOptions{ + Name: "foo", + AutoDestroyActivityDuration: jsonapi.NewNullableAttrWithValue("20m"), + }) + assert.Nil(t, w) + assert.Contains(t, err.Error(), "invalid attribute\n\nAuto destroy activity duration has an incorrect format, we expect up to 4 numeric digits and 1 unit ('d' or 'h')") + }) } func TestProjectsUpdate(t *testing.T) { @@ -284,6 +299,21 @@ func TestProjectsUpdate(t *testing.T) { assert.Nil(t, w) assert.EqualError(t, err, ErrInvalidProjectID.Error()) }) + + t.Run("without a valid projects auto destroy activity duration", func(t *testing.T) { + skipUnlessBeta(t) + + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) + + kBefore, kTestCleanup := createProject(t, client, orgTest) + defer kTestCleanup() + + w, err := client.Projects.Update(ctx, kBefore.ID, ProjectUpdateOptions{ + AutoDestroyActivityDuration: jsonapi.NewNullableAttrWithValue("bar"), + }) + assert.Nil(t, w) + assert.Contains(t, err.Error(), "invalid attribute\n\nAuto destroy activity duration has an incorrect format, we expect up to 4 numeric digits and 1 unit ('d' or 'h')") + }) } func TestProjectsAddTagBindings(t *testing.T) { @@ -350,6 +380,33 @@ func TestProjectsAddTagBindings(t *testing.T) { }) } +func TestProjects_DeleteAllTagBindings(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + pTest, wCleanup := createProject(t, client, nil) + t.Cleanup(wCleanup) + + tagBindings := []*TagBinding{ + {Key: "foo", Value: "bar"}, + {Key: "baz", Value: "qux"}, + } + + _, err := client.Projects.AddTagBindings(ctx, pTest.ID, ProjectAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.NoError(t, err) + + err = client.Projects.DeleteAllTagBindings(ctx, pTest.ID) + require.NoError(t, err) + + bindings, err := client.Projects.ListTagBindings(ctx, pTest.ID) + require.NoError(t, err) + require.Empty(t, bindings) +} + func TestProjectsDelete(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -378,3 +435,32 @@ func TestProjectsDelete(t *testing.T) { assert.EqualError(t, err, ErrInvalidProjectID.Error()) }) } + +func TestProjectsAutoDestroy(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) + + t.Run("when creating workspace in project with autodestroy", func(t *testing.T) { + options := ProjectCreateOptions{ + Name: "foo", + Description: String("qux"), + AutoDestroyActivityDuration: jsonapi.NewNullableAttrWithValue("3d"), + } + + p, err := client.Projects.Create(ctx, orgTest.Name, options) + require.NoError(t, err) + + w, _ := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + Name: String(randomString(t)), + Project: p, + }) + + assert.Equal(t, p.AutoDestroyActivityDuration, w.AutoDestroyActivityDuration) + }) +} diff --git a/registry_module_integration_test.go b/registry_module_integration_test.go index 805921fb0..35292604b 100644 --- a/registry_module_integration_test.go +++ b/registry_module_integration_test.go @@ -1763,7 +1763,6 @@ func TestRegistryModulesUploadTarGzip(t *testing.T) { packer, err := slug.NewPacker( slug.DereferenceSymlinks(), slug.ApplyTerraformIgnore(), - slug.AllowSymlinkTarget("/target/symlink/path/foo"), ) require.NoError(t, err) diff --git a/run_trigger.go b/run_trigger.go index e002271fa..930528bac 100644 --- a/run_trigger.go +++ b/run_trigger.go @@ -121,6 +121,10 @@ func (s *runTriggers) List(ctx context.Context, workspaceID string, options *Run return nil, err } + for i := range rtl.Items { + backfillDeprecatedSourceable(rtl.Items[i]) + } + return rtl, nil } @@ -145,6 +149,8 @@ func (s *runTriggers) Create(ctx context.Context, workspaceID string, options Ru return nil, err } + backfillDeprecatedSourceable(rt) + return rt, nil } @@ -166,6 +172,8 @@ func (s *runTriggers) Read(ctx context.Context, runTriggerID string) (*RunTrigge return nil, err } + backfillDeprecatedSourceable(rt) + return rt, nil } @@ -203,6 +211,14 @@ func (o *RunTriggerListOptions) valid() error { return nil } +func backfillDeprecatedSourceable(runTrigger *RunTrigger) { + if runTrigger.Sourceable != nil || runTrigger.SourceableChoice == nil { + return + } + + runTrigger.Sourceable = runTrigger.SourceableChoice.Workspace +} + func validateRunTriggerFilterParam(filterParam RunTriggerFilterOp, includeParams []RunTriggerIncludeOpt) error { switch filterParam { case RunTriggerOutbound, RunTriggerInbound: diff --git a/stack.go b/stack.go index d45956450..7cb95bd8b 100644 --- a/stack.go +++ b/stack.go @@ -75,6 +75,14 @@ type StackVCSRepo struct { OAuthTokenID string `jsonapi:"attr,oauth-token-id,omitempty"` } +// StackVCSRepoOptions +type StackVCSRepoOptions struct { + Identifier string `json:"identifier"` + Branch string `json:"branch,omitempty"` + GHAInstallationID string `json:"github-app-installation-id,omitempty"` + OAuthTokenID string `json:"oauth-token-id,omitempty"` +} + // Stack represents a stack. type Stack struct { ID string `jsonapi:"primary,stacks"` @@ -172,11 +180,11 @@ type StackReadOptions struct { // StackCreateOptions represents the options for creating a stack. The project // relation is required. type StackCreateOptions struct { - Type string `jsonapi:"primary,stacks"` - Name string `jsonapi:"attr,name"` - Description *string `jsonapi:"attr,description,omitempty"` - VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo"` - Project *Project `jsonapi:"relation,project"` + Type string `jsonapi:"primary,stacks"` + Name string `jsonapi:"attr,name"` + Description *string `jsonapi:"attr,description,omitempty"` + VCSRepo *StackVCSRepoOptions `jsonapi:"attr,vcs-repo"` + Project *Project `jsonapi:"relation,project"` } // StackUpdateOptions represents the options for updating a stack. @@ -326,7 +334,7 @@ func (s StackCreateOptions) valid() error { return s.VCSRepo.valid() } -func (s StackVCSRepo) valid() error { +func (s StackVCSRepoOptions) valid() error { if s.Identifier == "" { return ErrRequiredVCSRepo } diff --git a/stack_integration_test.go b/stack_integration_test.go index 437120103..d8d82b554 100644 --- a/stack_integration_test.go +++ b/stack_integration_test.go @@ -31,7 +31,7 @@ func TestStackCreateAndList(t *testing.T) { stack1, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "aa-test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "hashicorp-guides/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, }, @@ -45,7 +45,7 @@ func TestStackCreateAndList(t *testing.T) { stack2, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "zz-test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "hashicorp-guides/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, }, @@ -143,7 +143,7 @@ func TestStackReadUpdateDelete(t *testing.T) { stack, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "brandonc/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, Branch: "main", @@ -200,7 +200,7 @@ func TestStackReadUpdateForceDelete(t *testing.T) { stack, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "brandonc/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, Branch: "main", @@ -356,7 +356,7 @@ func TestStackConverged(t *testing.T) { stack, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "brandonc/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, }, diff --git a/stack_plan_integration_test.go b/stack_plan_integration_test.go index ea5bbe834..29ae34a8a 100644 --- a/stack_plan_integration_test.go +++ b/stack_plan_integration_test.go @@ -29,7 +29,7 @@ func TestStackPlanList(t *testing.T) { stack, err := client.Stacks.Create(ctx, StackCreateOptions{ Name: "aa-test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "brandonc/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, }, diff --git a/stack_source_integration_test.go b/stack_source_integration_test.go index fafaaa617..ab00fc8f2 100644 --- a/stack_source_integration_test.go +++ b/stack_source_integration_test.go @@ -26,7 +26,7 @@ func TestStackSourceCreateUploadAndRead(t *testing.T) { stack, err := client.Stacks.Create(ctx, StackCreateOptions{ Project: orgTest.DefaultProject, Name: "test-stack", - VCSRepo: &StackVCSRepo{ + VCSRepo: &StackVCSRepoOptions{ Identifier: "hashicorp-guides/pet-nulls-stack", OAuthTokenID: oauthClient.OAuthTokens[0].ID, }, diff --git a/team_project_access.go b/team_project_access.go index a08d0992f..36e9cda8d 100644 --- a/team_project_access.go +++ b/team_project_access.go @@ -71,6 +71,9 @@ type TeamProjectAccess struct { type TeamProjectAccessProjectPermissions struct { ProjectSettingsPermission ProjectSettingsPermissionType `jsonapi:"attr,settings"` ProjectTeamsPermission ProjectTeamsPermissionType `jsonapi:"attr,teams"` + // ProjectVariableSetsPermission represents read, manage, and no access custom permission for project-level variable sets + // This relation is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. + ProjectVariableSetsPermission ProjectVariableSetsPermissionType `jsonapi:"attr,variable-sets"` } // WorkspacePermissions represents the team's permission on all workspaces in its project @@ -104,6 +107,16 @@ const ( ProjectTeamsPermissionManage ProjectTeamsPermissionType = "manage" ) +// ProjectVariableSetsPermissionType represents the permission type to a project's variable sets +// This relation is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. +type ProjectVariableSetsPermissionType string + +const ( + ProjectVariableSetsPermissionNone ProjectVariableSetsPermissionType = "none" + ProjectVariableSetsPermissionRead ProjectVariableSetsPermissionType = "read" + ProjectVariableSetsPermissionWrite ProjectVariableSetsPermissionType = "write" +) + // WorkspaceRunsPermissionType represents the permissiontype to project workspaces' runs type WorkspaceRunsPermissionType string @@ -143,6 +156,8 @@ const ( type TeamProjectAccessProjectPermissionsOptions struct { Settings *ProjectSettingsPermissionType `json:"settings,omitempty"` Teams *ProjectTeamsPermissionType `json:"teams,omitempty"` + // This relation is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. + VariableSets *ProjectVariableSetsPermissionType `json:"variable-sets,omitempty"` } type TeamProjectAccessWorkspacePermissionsOptions struct { diff --git a/team_project_access_integration_test.go b/team_project_access_integration_test.go index 831c1f1c6..9770f6357 100644 --- a/team_project_access_integration_test.go +++ b/team_project_access_integration_test.go @@ -165,6 +165,58 @@ func TestTeamProjectAccessesAdd(t *testing.T) { } }) + t.Run("with no project access options for custom TeamProject permissions", func(t *testing.T) { + skipUnlessBeta(t) + options := TeamProjectAccessAddOptions{ + Access: *ProjectAccess(TeamProjectAccessCustom), + Team: tmTest, + Project: pTest, + ProjectAccess: &TeamProjectAccessProjectPermissionsOptions{}, + WorkspaceAccess: &TeamProjectAccessWorkspacePermissionsOptions{ + Runs: WorkspaceRunsPermission(WorkspaceRunsPermissionApply), + SentinelMocks: WorkspaceSentinelMocksPermission(WorkspaceSentinelMocksPermissionRead), + StateVersions: WorkspaceStateVersionsPermission(WorkspaceStateVersionsPermissionWrite), + Variables: WorkspaceVariablesPermission(WorkspaceVariablesPermissionWrite), + Create: Bool(true), + Locking: Bool(true), + Move: Bool(true), + Delete: Bool(false), + RunTasks: Bool(false), + }, + } + + tpa, err := client.TeamProjectAccess.Add(ctx, options) + defer func() { + err := client.TeamProjectAccess.Remove(ctx, tpa.ID) + if err != nil { + t.Logf("error removing team access (%s): %s", tpa.ID, err) + } + }() + + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.TeamProjectAccess.Read(ctx, tpa.ID) + require.NoError(t, err) + + for _, item := range []*TeamProjectAccess{ + tpa, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, options.Access, item.Access) + assert.Equal(t, *options.WorkspaceAccess.Runs, item.WorkspaceAccess.WorkspaceRunsPermission) + assert.Equal(t, *options.WorkspaceAccess.SentinelMocks, item.WorkspaceAccess.WorkspaceSentinelMocksPermission) + assert.Equal(t, *options.WorkspaceAccess.StateVersions, item.WorkspaceAccess.WorkspaceStateVersionsPermission) + assert.Equal(t, *options.WorkspaceAccess.Variables, item.WorkspaceAccess.WorkspaceVariablesPermission) + assert.Equal(t, item.WorkspaceAccess.WorkspaceCreatePermission, true) + assert.Equal(t, item.WorkspaceAccess.WorkspaceLockingPermission, true) + assert.Equal(t, item.WorkspaceAccess.WorkspaceMovePermission, true) + assert.Equal(t, item.WorkspaceAccess.WorkspaceDeletePermission, false) + assert.Equal(t, item.WorkspaceAccess.WorkspaceRunTasksPermission, false) + } + }) + t.Run("with valid options for all custom TeamProject permissions", func(t *testing.T) { options := TeamProjectAccessAddOptions{ Access: *ProjectAccess(TeamProjectAccessCustom), @@ -221,6 +273,45 @@ func TestTeamProjectAccessesAdd(t *testing.T) { } }) + t.Run("with valid options for custom variable sets permissions", func(t *testing.T) { + skipUnlessBeta(t) + options := TeamProjectAccessAddOptions{ + Access: *ProjectAccess(TeamProjectAccessCustom), + Team: tmTest, + Project: pTest, + ProjectAccess: &TeamProjectAccessProjectPermissionsOptions{ + VariableSets: ProjectVariableSetsPermission(ProjectVariableSetsPermissionWrite), + }, + WorkspaceAccess: &TeamProjectAccessWorkspacePermissionsOptions{ + Runs: WorkspaceRunsPermission(WorkspaceRunsPermissionApply), + }, + } + + tpa, err := client.TeamProjectAccess.Add(ctx, options) + t.Cleanup(func() { + err := client.TeamProjectAccess.Remove(ctx, tpa.ID) + if err != nil { + t.Logf("error removing team access (%s): %s", tpa.ID, err) + } + }) + + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.TeamProjectAccess.Read(ctx, tpa.ID) + require.NoError(t, err) + + for _, item := range []*TeamProjectAccess{ + tpa, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, options.Access, item.Access) + assert.Equal(t, *options.ProjectAccess.VariableSets, item.ProjectAccess.ProjectVariableSetsPermission) + assert.Equal(t, *options.WorkspaceAccess.Runs, item.WorkspaceAccess.WorkspaceRunsPermission) + } + }) + t.Run("with valid options for some custom TeamProject permissions", func(t *testing.T) { options := TeamProjectAccessAddOptions{ Access: *ProjectAccess(TeamProjectAccessCustom), @@ -389,6 +480,48 @@ func TestTeamProjectAccessesUpdate(t *testing.T) { assert.Equal(t, true, tpa.WorkspaceAccess.WorkspaceRunTasksPermission) }) + t.Run("with valid custom permissions attributes for variable sets permissions", func(t *testing.T) { + skipUnlessBeta(t) + // create tpaCustomTest to verify unupdated attributes stay the same for custom permissions + // because going from admin to read to custom changes the values of all custom permissions + tm2Test, tm2TestCleanup := createTeam(t, client, orgTest) + defer tm2TestCleanup() + + TpaOptions := TeamProjectAccessAddOptions{ + Access: *ProjectAccess(TeamProjectAccessCustom), + Team: tm2Test, + Project: pTest, + } + + tpaCustomTest, err := client.TeamProjectAccess.Add(ctx, TpaOptions) + require.NoError(t, err) + + options := TeamProjectAccessUpdateOptions{ + Access: ProjectAccess(TeamProjectAccessCustom), + ProjectAccess: &TeamProjectAccessProjectPermissionsOptions{ + VariableSets: ProjectVariableSetsPermission(ProjectVariableSetsPermissionRead), + }, + WorkspaceAccess: &TeamProjectAccessWorkspacePermissionsOptions{ + Create: Bool(false), + }, + } + + tpa, err := client.TeamProjectAccess.Update(ctx, tpaCustomTest.ID, options) + require.NoError(t, err) + require.NotNil(t, options.ProjectAccess) + require.NotNil(t, options.WorkspaceAccess) + assert.Equal(t, *options.ProjectAccess.VariableSets, tpa.ProjectAccess.ProjectVariableSetsPermission) + assert.Equal(t, false, tpa.WorkspaceAccess.WorkspaceCreatePermission) + // assert that other attributes remain the same + assert.Equal(t, tpaCustomTest.ProjectAccess.ProjectSettingsPermission, tpa.ProjectAccess.ProjectSettingsPermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceLockingPermission, tpa.WorkspaceAccess.WorkspaceLockingPermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceMovePermission, tpa.WorkspaceAccess.WorkspaceMovePermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceDeletePermission, tpa.WorkspaceAccess.WorkspaceDeletePermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceRunsPermission, tpa.WorkspaceAccess.WorkspaceRunsPermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceSentinelMocksPermission, tpa.WorkspaceAccess.WorkspaceSentinelMocksPermission) + assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceStateVersionsPermission, tpa.WorkspaceAccess.WorkspaceStateVersionsPermission) + }) + t.Run("with valid custom permissions attributes for some permissions", func(t *testing.T) { // create tpaCustomTest to verify unupdated attributes stay the same for custom permissions // because going from admin to read to custom changes the values of all custom permissions @@ -429,6 +562,7 @@ func TestTeamProjectAccessesUpdate(t *testing.T) { assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceSentinelMocksPermission, tpa.WorkspaceAccess.WorkspaceSentinelMocksPermission) assert.Equal(t, tpaCustomTest.WorkspaceAccess.WorkspaceStateVersionsPermission, tpa.WorkspaceAccess.WorkspaceStateVersionsPermission) }) + t.Run("with invalid custom permissions attributes", func(t *testing.T) { options := TeamProjectAccessUpdateOptions{ Access: ProjectAccess(TeamProjectAccessCustom), diff --git a/type_helpers.go b/type_helpers.go index 1ca99d2e5..24252f273 100644 --- a/type_helpers.go +++ b/type_helpers.go @@ -29,6 +29,11 @@ func ProjectTeamsPermission(v ProjectTeamsPermissionType) *ProjectTeamsPermissio return &v } +// ProjectVariableSetsPermission returns a pointer to the given team access project type. +func ProjectVariableSetsPermission(v ProjectVariableSetsPermissionType) *ProjectVariableSetsPermissionType { + return &v +} + // WorkspaceRunsPermission returns a pointer to the given team access project type. func WorkspaceRunsPermission(v WorkspaceRunsPermissionType) *WorkspaceRunsPermissionType { return &v diff --git a/workspace.go b/workspace.go index 1a3990dbb..be1004686 100644 --- a/workspace.go +++ b/workspace.go @@ -141,6 +141,9 @@ type Workspaces interface { // AddTagBindings adds or modifies the value of existing tag binding keys for a workspace. AddTagBindings(ctx context.Context, workspaceID string, options WorkspaceAddTagBindingsOptions) ([]*TagBinding, error) + + // DeleteAllTagBindings removes all tag bindings for a workspace. + DeleteAllTagBindings(ctx context.Context, workspaceID string) error } // workspaces implements Workspaces. @@ -186,6 +189,7 @@ type Workspace struct { ExecutionMode string `jsonapi:"attr,execution-mode"` FileTriggersEnabled bool `jsonapi:"attr,file-triggers-enabled"` GlobalRemoteState bool `jsonapi:"attr,global-remote-state"` + InheritsProjectAutoDestroy bool `jsonapi:"attr,inherits-project-auto-destroy"` Locked bool `jsonapi:"attr,locked"` MigrationEnvironment string `jsonapi:"attr,migration-environment"` Name string `jsonapi:"attr,name"` @@ -393,6 +397,9 @@ type WorkspaceCreateOptions struct { // should roughly match a Go duration string limited to days and hours, e.g. "24h" or "1d". AutoDestroyActivityDuration jsonapi.NullableAttr[string] `jsonapi:"attr,auto-destroy-activity-duration,omitempty"` + // Optional: Whether the workspace inherits auto destroy settings from the project + InheritsProjectAutoDestroy *bool `jsonapi:"attr,inherits-project-auto-destroy,omitempty"` + // Optional: A description for the workspace. Description *string `jsonapi:"attr,description,omitempty"` @@ -550,6 +557,9 @@ type WorkspaceUpdateOptions struct { // should roughly match a Go duration string limited to days and hours, e.g. "24h" or "1d". AutoDestroyActivityDuration jsonapi.NullableAttr[string] `jsonapi:"attr,auto-destroy-activity-duration,omitempty"` + // Optional: Whether the workspace inherits auto destroy settings from the project + InheritsProjectAutoDestroy *bool `jsonapi:"attr,inherits-project-auto-destroy,omitempty"` + // Optional: A new name for the workspace, which can only include letters, numbers, -, // and _. This will be used as an identifier and must be unique in the // organization. Warning: Changing a workspace's name changes its URL in the @@ -822,6 +832,32 @@ func (s *workspaces) AddTagBindings(ctx context.Context, workspaceID string, opt return response.Items, err } +// DeleteAllTagBindings removes all tag bindings associated with a workspace. +// This method will not remove any inherited tag bindings, which must be +// explicitly removed from the parent project. +func (s *workspaces) DeleteAllTagBindings(ctx context.Context, workspaceID string) error { + if !validStringID(&workspaceID) { + return ErrInvalidWorkspaceID + } + + type aliasOpts struct { + Type string `jsonapi:"primary,workspaces"` + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings"` + } + + opts := &aliasOpts{ + TagBindings: []*TagBinding{}, + } + + u := fmt.Sprintf("workspaces/%s", url.PathEscape(workspaceID)) + req, err := s.client.NewRequest("PATCH", u, opts) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + // Create is used to create a new workspace. func (s *workspaces) Create(ctx context.Context, organization string, options WorkspaceCreateOptions) (*Workspace, error) { if !validStringID(&organization) { diff --git a/workspace_integration_test.go b/workspace_integration_test.go index 1505716a6..04b28c543 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -1237,6 +1237,33 @@ func TestWorkspacesAddTagBindings(t *testing.T) { }) } +func TestWorkspaces_DeleteAllTagBindings(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + wTest, wCleanup := createWorkspace(t, client, nil) + t.Cleanup(wCleanup) + + tagBindings := []*TagBinding{ + {Key: "foo", Value: "bar"}, + {Key: "baz", Value: "qux"}, + } + + _, err := client.Workspaces.AddTagBindings(ctx, wTest.ID, WorkspaceAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.NoError(t, err) + + err = client.Workspaces.DeleteAllTagBindings(ctx, wTest.ID) + require.NoError(t, err) + + bindings, err := client.Workspaces.ListTagBindings(ctx, wTest.ID) + require.NoError(t, err) + require.Empty(t, bindings) +} + func TestWorkspacesUpdate(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -2961,7 +2988,7 @@ func TestWorkspacesAutoDestroy(t *testing.T) { orgTest, orgTestCleanup := createOrganization(t, client) t.Cleanup(orgTestCleanup) - upgradeOrganizationSubscription(t, client, orgTest) + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) autoDestroyAt := NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) wTest, wCleanup := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ @@ -2999,31 +3026,39 @@ func TestWorkspacesAutoDestroy(t *testing.T) { } func TestWorkspacesAutoDestroyDuration(t *testing.T) { + skipUnlessBeta(t) + client := testClient(t) ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) t.Cleanup(orgTestCleanup) - upgradeOrganizationSubscription(t, client, orgTest) + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) - duration := jsonapi.NewNullableAttrWithValue("14d") - nilDuration := jsonapi.NewNullNullableAttr[string]() - nilAutoDestroy := jsonapi.NewNullNullableAttr[time.Time]() - wTest, wCleanup := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ - Name: String(randomString(t)), - AutoDestroyActivityDuration: duration, - }) - t.Cleanup(wCleanup) + t.Run("when creating a new workspace with standalone auto destroy settings", func(t *testing.T) { + duration := jsonapi.NewNullableAttrWithValue("14d") + nilDuration := jsonapi.NewNullNullableAttr[string]() + nilAutoDestroy := jsonapi.NewNullNullableAttr[time.Time]() + wTest, wCleanup := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + Name: String(randomString(t)), + AutoDestroyActivityDuration: duration, + InheritsProjectAutoDestroy: Bool(false), + }) + t.Cleanup(wCleanup) - require.Equal(t, duration, wTest.AutoDestroyActivityDuration) - require.NotEqual(t, nilAutoDestroy, wTest.AutoDestroyAt) + require.Equal(t, duration, wTest.AutoDestroyActivityDuration) + require.NotEqual(t, nilAutoDestroy, wTest.AutoDestroyAt) + require.Equal(t, wTest.InheritsProjectAutoDestroy, false) - w, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, WorkspaceUpdateOptions{ - AutoDestroyActivityDuration: nilDuration, - }) + w, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, WorkspaceUpdateOptions{ + AutoDestroyActivityDuration: nilDuration, + InheritsProjectAutoDestroy: Bool(false), + }) - require.NoError(t, err) - require.False(t, w.AutoDestroyActivityDuration.IsSpecified()) - require.False(t, w.AutoDestroyAt.IsSpecified()) + require.NoError(t, err) + require.False(t, w.AutoDestroyActivityDuration.IsSpecified()) + require.False(t, w.AutoDestroyAt.IsSpecified()) + require.Equal(t, wTest.InheritsProjectAutoDestroy, false) + }) }