From db4f3f8bc519ac0664895bf1b6a0fd6807fec9a5 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 30 Apr 2020 14:56:32 -0700 Subject: [PATCH 1/5] vendor: go get github.com/hashicorp/go-tfe@v0.8.0 This includes a new TargetAddrs field on both Run and RunCreateOptions which we'll use to send resource addresses that were specified using -target on the CLI command line when using the remote backend. There were some unrelated upstream breaking changes compared to the last version we had vendored, so this commit also includes some changes to the backend/remote package to work with this new API, which now requires the remote backend to be aware of the remote system's opaque workspace id. --- backend/remote/backend_context.go | 58 +++-- backend/remote/backend_context_test.go | 13 +- backend/remote/backend_mock.go | 16 +- go.mod | 2 +- go.sum | 4 +- vendor/github.com/hashicorp/go-tfe/README.md | 9 +- vendor/github.com/hashicorp/go-tfe/go.mod | 2 + vendor/github.com/hashicorp/go-tfe/go.sum | 4 - .../hashicorp/go-tfe/oauth_client.go | 6 + .../go-tfe/organization_membership.go | 178 ++++++++++++++ .../github.com/hashicorp/go-tfe/policy_set.go | 13 + .../hashicorp/go-tfe/policy_set_parameter.go | 230 ++++++++++++++++++ vendor/github.com/hashicorp/go-tfe/run.go | 14 ++ .../hashicorp/go-tfe/run_trigger.go | 177 ++++++++++++++ vendor/github.com/hashicorp/go-tfe/team.go | 10 +- .../hashicorp/go-tfe/team_member.go | 170 ++++++++++--- vendor/github.com/hashicorp/go-tfe/testing.go | 36 +++ vendor/github.com/hashicorp/go-tfe/tfe.go | 83 +++++-- .../github.com/hashicorp/go-tfe/variable.go | 76 +++--- vendor/modules.txt | 2 +- 20 files changed, 983 insertions(+), 120 deletions(-) create mode 100644 vendor/github.com/hashicorp/go-tfe/organization_membership.go create mode 100644 vendor/github.com/hashicorp/go-tfe/policy_set_parameter.go create mode 100644 vendor/github.com/hashicorp/go-tfe/run_trigger.go create mode 100644 vendor/github.com/hashicorp/go-tfe/testing.go diff --git a/backend/remote/backend_context.go b/backend/remote/backend_context.go index 9461cb54a..13202f547 100644 --- a/backend/remote/backend_context.go +++ b/backend/remote/backend_context.go @@ -30,23 +30,17 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu } // Get the remote workspace name. - workspace := op.Workspace - switch { - case op.Workspace == backend.DefaultStateName: - workspace = b.workspace - case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): - workspace = b.prefix + op.Workspace - } + remoteWorkspaceName := b.getRemoteWorkspaceName(op.Workspace) // Get the latest state. - log.Printf("[TRACE] backend/remote: requesting state manager for workspace %q", workspace) + log.Printf("[TRACE] backend/remote: requesting state manager for workspace %q", remoteWorkspaceName) stateMgr, err := b.StateMgr(op.Workspace) if err != nil { diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) return nil, nil, diags } - log.Printf("[TRACE] backend/remote: requesting state lock for workspace %q", workspace) + log.Printf("[TRACE] backend/remote: requesting state lock for workspace %q", remoteWorkspaceName) if err := op.StateLocker.Lock(stateMgr, op.Type.String()); err != nil { diags = diags.Append(errwrap.Wrapf("Error locking state: {{err}}", err)) return nil, nil, diags @@ -63,7 +57,7 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu } }() - log.Printf("[TRACE] backend/remote: reading remote state for workspace %q", workspace) + log.Printf("[TRACE] backend/remote: reading remote state for workspace %q", remoteWorkspaceName) if err := stateMgr.RefreshState(); err != nil { diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) return nil, nil, diags @@ -83,7 +77,7 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu // Load the latest state. If we enter contextFromPlanFile below then the // state snapshot in the plan file must match this, or else it'll return // error diagnostics. - log.Printf("[TRACE] backend/remote: retrieving remote state snapshot for workspace %q", workspace) + log.Printf("[TRACE] backend/remote: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) opts.State = stateMgr.State() log.Printf("[TRACE] backend/remote: loading configuration for the current working directory") @@ -94,11 +88,17 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu } opts.Config = config - log.Printf("[TRACE] backend/remote: retrieving variables from workspace %q", workspace) - tfeVariables, err := b.client.Variables.List(context.Background(), tfe.VariableListOptions{ - Organization: tfe.String(b.organization), - Workspace: tfe.String(workspace), - }) + // The underlying API expects us to use the opaque workspace id to request + // variables, so we'll need to look that up using our organization name + // and workspace name. + remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) + if err != nil { + diags = diags.Append(errwrap.Wrapf("Error finding remote workspace: {{err}}", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] backend/remote: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) if err != nil && err != tfe.ErrResourceNotFound { diags = diags.Append(errwrap.Wrapf("Error loading variables: {{err}}", err)) return nil, nil, diags @@ -142,6 +142,32 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu return tfCtx, stateMgr, diags } +func (b *Remote) getRemoteWorkspaceName(localWorkspaceName string) string { + switch { + case localWorkspaceName == backend.DefaultStateName: + // The default workspace name is a special case, for when the backend + // is configured to with to an exact remote workspace rather than with + // a remote workspace _prefix_. + return b.workspace + case b.prefix != "" && !strings.HasPrefix(localWorkspaceName, b.prefix): + return b.prefix + localWorkspaceName + default: + return localWorkspaceName + } +} + +func (b *Remote) getRemoteWorkspaceID(ctx context.Context, localWorkspaceName string) (string, error) { + remoteWorkspaceName := b.getRemoteWorkspaceName(localWorkspaceName) + + log.Printf("[TRACE] backend/remote: looking up workspace id for %s/%s", b.organization, remoteWorkspaceName) + remoteWorkspace, err := b.client.Workspaces.Read(ctx, b.organization, remoteWorkspaceName) + if err != nil { + return "", err + } + + return remoteWorkspace.ID, nil +} + func stubAllVariables(vv map[string]backend.UnparsedVariableValue, decls map[string]*configs.Variable) terraform.InputValues { ret := make(terraform.InputValues, len(decls)) diff --git a/backend/remote/backend_context_test.go b/backend/remote/backend_context_test.go index 03de0f427..a7fa5e713 100644 --- a/backend/remote/backend_context_test.go +++ b/backend/remote/backend_context_test.go @@ -1,6 +1,7 @@ package remote import ( + "context" "testing" tfe "github.com/hashicorp/go-tfe" @@ -176,6 +177,11 @@ func TestRemoteContextWithVars(t *testing.T) { _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) defer configCleanup() + workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) + if err != nil { + t.Fatal(err) + } + op := &backend.Operation{ ConfigDir: configDir, ConfigLoader: configLoader, @@ -187,12 +193,7 @@ func TestRemoteContextWithVars(t *testing.T) { key := "key" v.Key = &key } - if v.Workspace == nil { - v.Workspace = &tfe.Workspace{ - Name: b.workspace, - } - } - b.client.Variables.Create(nil, *v) + b.client.Variables.Create(nil, workspaceID, *v) _, _, diags := b.Context(op) diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index 42b51fd26..d1e7e23e2 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -952,6 +952,8 @@ type mockVariables struct { workspaces map[string]*tfe.VariableList } +var _ tfe.Variables = (*mockVariables)(nil) + func newMockVariables(client *mockClient) *mockVariables { return &mockVariables{ client: client, @@ -959,12 +961,12 @@ func newMockVariables(client *mockClient) *mockVariables { } } -func (m *mockVariables) List(ctx context.Context, options tfe.VariableListOptions) (*tfe.VariableList, error) { - vl := m.workspaces[*options.Workspace] +func (m *mockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { + vl := m.workspaces[workspaceID] return vl, nil } -func (m *mockVariables) Create(ctx context.Context, options tfe.VariableCreateOptions) (*tfe.Variable, error) { +func (m *mockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { v := &tfe.Variable{ ID: generateID("var-"), Key: *options.Key, @@ -980,7 +982,7 @@ func (m *mockVariables) Create(ctx context.Context, options tfe.VariableCreateOp v.Sensitive = *options.Sensitive } - workspace := options.Workspace.Name + workspace := workspaceID if m.workspaces[workspace] == nil { m.workspaces[workspace] = &tfe.VariableList{} @@ -992,15 +994,15 @@ func (m *mockVariables) Create(ctx context.Context, options tfe.VariableCreateOp return v, nil } -func (m *mockVariables) Read(ctx context.Context, variableID string) (*tfe.Variable, error) { +func (m *mockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Update(ctx context.Context, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { +func (m *mockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Delete(ctx context.Context, variableID string) error { +func (m *mockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { panic("not implemented") } diff --git a/go.mod b/go.mod index 0884e719a..3fdcf327f 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.5.2 github.com/hashicorp/go-rootcerts v1.0.0 github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 // indirect - github.com/hashicorp/go-tfe v0.3.27 + github.com/hashicorp/go-tfe v0.8.0 github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index 5ff411f07..a555520eb 100644 --- a/go.sum +++ b/go.sum @@ -231,8 +231,8 @@ github.com/hashicorp/go-slug v0.4.1 h1:/jAo8dNuLgSImoLXaX7Od7QB4TfYCVPam+OpAt5bZ github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2YWnJZkQp7B5eFykaIY7C9JndqAFQyVV5BhM= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-tfe v0.3.27 h1:7XZ/ZoPyYoeuNXaWWW0mJOq016y0qb7I4Q0P/cagyu8= -github.com/hashicorp/go-tfe v0.3.27/go.mod h1:DVPSW2ogH+M9W1/i50ASgMht8cHP7NxxK0nrY9aFikQ= +github.com/hashicorp/go-tfe v0.8.0 h1:kz3x3tbIKRkEAzKg05P/qbFY88fkEU7TiSX3w8xUrmE= +github.com/hashicorp/go-tfe v0.8.0/go.mod h1:XAV72S4O1iP8BDaqiaPLmL2B4EE6almocnOn8E8stHc= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= diff --git a/vendor/github.com/hashicorp/go-tfe/README.md b/vendor/github.com/hashicorp/go-tfe/README.md index 3ebdb76b6..cba053e5a 100644 --- a/vendor/github.com/hashicorp/go-tfe/README.md +++ b/vendor/github.com/hashicorp/go-tfe/README.md @@ -26,19 +26,22 @@ Currently the following endpoints are supported: - [x] [OAuth Clients](https://www.terraform.io/docs/enterprise/api/oauth-clients.html) - [x] [OAuth Tokens](https://www.terraform.io/docs/enterprise/api/oauth-tokens.html) - [x] [Organizations](https://www.terraform.io/docs/enterprise/api/organizations.html) +- [x] [Organization Memberships](https://www.terraform.io/docs/cloud/api/organization-memberships.html) - [x] [Organization Tokens](https://www.terraform.io/docs/enterprise/api/organization-tokens.html) - [x] [Policies](https://www.terraform.io/docs/enterprise/api/policies.html) +- [x] [Policy Set Parameters](https://www.terraform.io/docs/enterprise/api/policy-set-params.html) - [x] [Policy Sets](https://www.terraform.io/docs/enterprise/api/policy-sets.html) - [x] [Policy Checks](https://www.terraform.io/docs/enterprise/api/policy-checks.html) - [ ] [Registry Modules](https://www.terraform.io/docs/enterprise/api/modules.html) - [x] [Runs](https://www.terraform.io/docs/enterprise/api/run.html) +- [x] [Run Triggers](https://www.terraform.io/docs/cloud/api/run-triggers.html) - [x] [SSH Keys](https://www.terraform.io/docs/enterprise/api/ssh-keys.html) - [x] [State Versions](https://www.terraform.io/docs/enterprise/api/state-versions.html) - [x] [Team Access](https://www.terraform.io/docs/enterprise/api/team-access.html) - [x] [Team Memberships](https://www.terraform.io/docs/enterprise/api/team-members.html) - [x] [Team Tokens](https://www.terraform.io/docs/enterprise/api/team-tokens.html) - [x] [Teams](https://www.terraform.io/docs/enterprise/api/teams.html) -- [x] [Variables](https://www.terraform.io/docs/enterprise/api/variables.html) +- [x] [Workspace Variables](https://www.terraform.io/docs/enterprise/api/workspace-variables.html) - [x] [Workspaces](https://www.terraform.io/docs/enterprise/api/workspaces.html) - [ ] [Admin](https://www.terraform.io/docs/enterprise/api/admin/index.html) @@ -145,7 +148,7 @@ and token. 1. `TFE_TOKEN` - A [user API token](https://www.terraform.io/docs/cloud/users-teams-organizations/users.html#api-tokens) for the Terraform Cloud or Terraform Enterprise instance being used for testing. ##### Optional: -1. `GITHUB_TOKEN` - [GitHub personal access token](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). Required for running OAuth client tests. +1. `GITHUB_TOKEN` - [GitHub personal access token](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). Required for running any tests that use VCS (OAuth clients, policy sets, etc). 1. `GITHUB_POLICY_SET_IDENTIFIER` - GitHub policy set repository identifier in the format `username/repository`. Required for running policy set tests. You can set your environment variables up however you prefer. The following are instructions for setting up environment variables using [envchain](https://github.com/sorah/envchain). @@ -233,4 +236,4 @@ Documentation updates and test fixes that only touch test files don't require a - Don't attach any binaries. The zip and tar.gz assets are automatically created and attached after you publish your release. - Click "Publish release" to save and publish your release. - \ No newline at end of file + diff --git a/vendor/github.com/hashicorp/go-tfe/go.mod b/vendor/github.com/hashicorp/go-tfe/go.mod index 31ad255a1..7871cb399 100644 --- a/vendor/github.com/hashicorp/go-tfe/go.mod +++ b/vendor/github.com/hashicorp/go-tfe/go.mod @@ -11,3 +11,5 @@ require ( github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 ) + +go 1.12 diff --git a/vendor/github.com/hashicorp/go-tfe/go.sum b/vendor/github.com/hashicorp/go-tfe/go.sum index 4c0ab53b1..74c1e9523 100644 --- a/vendor/github.com/hashicorp/go-tfe/go.sum +++ b/vendor/github.com/hashicorp/go-tfe/go.sum @@ -8,10 +8,6 @@ github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6K github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-retryablehttp v0.5.2 h1:AoISa4P4IsW0/m4T6St8Yw38gTl5GtBAgfkhYh1xAz4= github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-slug v0.4.0 h1:YSz3afoEZZJVVB46NITf0+opd2cHpaYJ1XSojOyP0x8= -github.com/hashicorp/go-slug v0.4.0/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= -github.com/hashicorp/go-slug v0.4.1-0.20191114211806-d9ee9eb3692a h1:EmBGX5Ja8JEKRHqTDG9+PYq0qL5qyOUmPZFQfH7VfXo= -github.com/hashicorp/go-slug v0.4.1-0.20191114211806-d9ee9eb3692a/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-slug v0.4.1 h1:/jAo8dNuLgSImoLXaX7Od7QB4TfYCVPam+OpAt5bZqc= github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= diff --git a/vendor/github.com/hashicorp/go-tfe/oauth_client.go b/vendor/github.com/hashicorp/go-tfe/oauth_client.go index 35174441b..3a3c3168e 100644 --- a/vendor/github.com/hashicorp/go-tfe/oauth_client.go +++ b/vendor/github.com/hashicorp/go-tfe/oauth_client.go @@ -120,6 +120,9 @@ type OAuthClientCreateOptions struct { // The token string you were given by your VCS provider. OAuthToken *string `jsonapi:"attr,oauth-token-string"` + // Private key associated with this vcs provider - only available for ado_server + PrivateKey *string `jsonapi:"attr,private-key"` + // The VCS provider being connected with. ServiceProvider *ServiceProviderType `jsonapi:"attr,service-provider"` } @@ -137,6 +140,9 @@ func (o OAuthClientCreateOptions) valid() error { if o.ServiceProvider == nil { return errors.New("service provider is required") } + if validString(o.PrivateKey) && *o.ServiceProvider != *ServiceProvider(ServiceProviderAzureDevOpsServer) { + return errors.New("Private Key can only be present with Azure DevOps Server service provider") + } return nil } diff --git a/vendor/github.com/hashicorp/go-tfe/organization_membership.go b/vendor/github.com/hashicorp/go-tfe/organization_membership.go new file mode 100644 index 000000000..2806f929e --- /dev/null +++ b/vendor/github.com/hashicorp/go-tfe/organization_membership.go @@ -0,0 +1,178 @@ +package tfe + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ OrganizationMemberships = (*organizationMemberships)(nil) + +// OrganizationMemberships describes all the organization membership related methods that +// the Terraform Enterprise API supports. +// +// TFE API docs: +// https://www.terraform.io/docs/cloud/api/organization-memberships.html +type OrganizationMemberships interface { + // List all the organization memberships of the given organization. + List(ctx context.Context, organization string, options OrganizationMembershipListOptions) (*OrganizationMembershipList, error) + + // Create a new organization membership with the given options. + Create(ctx context.Context, organization string, options OrganizationMembershipCreateOptions) (*OrganizationMembership, error) + + // Read an organization membership by ID + Read(ctx context.Context, organizationMembershipID string) (*OrganizationMembership, error) + + // Read an organization membership by ID with options + ReadWithOptions(ctx context.Context, organizationMembershipID string, options OrganizationMembershipReadOptions) (*OrganizationMembership, error) + + // Delete an organization membership by its ID. + Delete(ctx context.Context, organizationMembershipID string) error +} + +// organizationMemberships implements OrganizationMemberships. +type organizationMemberships struct { + client *Client +} + +// OrganizationMembershipStatus represents an organization membership status. +type OrganizationMembershipStatus string + +// List all available organization membership statuses. +const ( + OrganizationMembershipActive = "active" + OrganizationMembershipInvited = "invited" +) + +// OrganizationMembershipList represents a list of organization memberships. +type OrganizationMembershipList struct { + *Pagination + Items []*OrganizationMembership +} + +// OrganizationMembership represents a Terraform Enterprise organization membership. +type OrganizationMembership struct { + ID string `jsonapi:"primary,organization-memberships"` + Status OrganizationMembershipStatus `jsonapi:"attr,status"` + Email string `jsonapi:"attr,email"` + + // Relations + Organization *Organization `jsonapi:"relation,organization"` + User *User `jsonapi:"relation,user"` + Teams []*Team `jsonapi:"relation,teams"` +} + +// OrganizationMembershipListOptions represents the options for listing organization memberships. +type OrganizationMembershipListOptions struct { + ListOptions + + Include string `url:"include"` +} + +// List all the organization memberships of the given organization. +func (s *organizationMemberships) List(ctx context.Context, organization string, options OrganizationMembershipListOptions) (*OrganizationMembershipList, error) { + if !validStringID(&organization) { + return nil, errors.New("invalid value for organization") + } + + u := fmt.Sprintf("organizations/%s/organization-memberships", url.QueryEscape(organization)) + req, err := s.client.newRequest("GET", u, &options) + if err != nil { + return nil, err + } + + ml := &OrganizationMembershipList{} + err = s.client.do(ctx, req, ml) + if err != nil { + return nil, err + } + + return ml, nil +} + +// OrganizationMembershipCreateOptions represents the options for creating an organization membership. +type OrganizationMembershipCreateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,organization-memberships"` + + // User's email address. + Email *string `jsonapi:"attr,email"` +} + +func (o OrganizationMembershipCreateOptions) valid() error { + if o.Email == nil { + return errors.New("email is required") + } + return nil +} + +// Create an organization membership with the given options. +func (s *organizationMemberships) Create(ctx context.Context, organization string, options OrganizationMembershipCreateOptions) (*OrganizationMembership, error) { + if !validStringID(&organization) { + return nil, errors.New("invalid value for organization") + } + if err := options.valid(); err != nil { + return nil, err + } + + options.ID = "" + + u := fmt.Sprintf("organizations/%s/organization-memberships", url.QueryEscape(organization)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + m := &OrganizationMembership{} + err = s.client.do(ctx, req, m) + if err != nil { + return nil, err + } + + return m, nil +} + +// Read an organization membership by its ID. +func (s *organizationMemberships) Read(ctx context.Context, organizationMembershipID string) (*OrganizationMembership, error) { + return s.ReadWithOptions(ctx, organizationMembershipID, OrganizationMembershipReadOptions{}) +} + +// OrganizationMembershipReadOptions represents the options for reading organization memberships. +type OrganizationMembershipReadOptions struct { + Include string `url:"include"` +} + +// Read an organization membership by ID with options +func (s *organizationMemberships) ReadWithOptions(ctx context.Context, organizationMembershipID string, options OrganizationMembershipReadOptions) (*OrganizationMembership, error) { + if !validStringID(&organizationMembershipID) { + return nil, errors.New("invalid value for membership") + } + + u := fmt.Sprintf("organization-memberships/%s", url.QueryEscape(organizationMembershipID)) + req, err := s.client.newRequest("GET", u, &options) + + mem := &OrganizationMembership{} + err = s.client.do(ctx, req, mem) + if err != nil { + return nil, err + } + + return mem, nil +} + +// Delete an organization membership by its ID. +func (s *organizationMemberships) Delete(ctx context.Context, organizationMembershipID string) error { + if !validStringID(&organizationMembershipID) { + return errors.New("invalid value for membership") + } + + u := fmt.Sprintf("organization-memberships/%s", url.QueryEscape(organizationMembershipID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/vendor/github.com/hashicorp/go-tfe/policy_set.go b/vendor/github.com/hashicorp/go-tfe/policy_set.go index b49e1bd2c..56a31e307 100644 --- a/vendor/github.com/hashicorp/go-tfe/policy_set.go +++ b/vendor/github.com/hashicorp/go-tfe/policy_set.go @@ -209,6 +209,19 @@ type PolicySetUpdateOptions struct { // Whether or not the policy set is global. Global *bool `jsonapi:"attr,global,omitempty"` + + // The sub-path within the attached VCS repository to ingress. All + // files and directories outside of this sub-path will be ignored. + // This option may only be specified when a VCS repo is present. + PoliciesPath *string `jsonapi:"attr,policies-path,omitempty"` + + // VCS repository information. When present, the policies and + // configuration will be sourced from the specified VCS repository + // instead of being defined within the policy set itself. Note that + // specifying this option may only be used on policy sets with no + // directly-attached policies (*PolicySet.Policies). Specifying this + // option when policies are already present will result in an error. + VCSRepo *VCSRepoOptions `jsonapi:"attr,vcs-repo,omitempty"` } func (o PolicySetUpdateOptions) valid() error { diff --git a/vendor/github.com/hashicorp/go-tfe/policy_set_parameter.go b/vendor/github.com/hashicorp/go-tfe/policy_set_parameter.go new file mode 100644 index 000000000..6e2587b90 --- /dev/null +++ b/vendor/github.com/hashicorp/go-tfe/policy_set_parameter.go @@ -0,0 +1,230 @@ +package tfe + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ PolicySetParameters = (*policySetParameters)(nil) + +// PolicySetParameters describes all the parameter related methods that the Terraform +// Enterprise API supports. +// +// TFE API docs: https://www.terraform.io/docs/enterprise/api/policy-set-params.html +type PolicySetParameters interface { + // List all the parameters associated with the given policy-set. + List(ctx context.Context, policySetID string, options PolicySetParameterListOptions) (*PolicySetParameterList, error) + + // Create is used to create a new parameter. + Create(ctx context.Context, policySetID string, options PolicySetParameterCreateOptions) (*PolicySetParameter, error) + + // Read a parameter by its ID. + Read(ctx context.Context, policySetID string, parameterID string) (*PolicySetParameter, error) + + // Update values of an existing parameter. + Update(ctx context.Context, policySetID string, parameterID string, options PolicySetParameterUpdateOptions) (*PolicySetParameter, error) + + // Delete a parameter by its ID. + Delete(ctx context.Context, policySetID string, parameterID string) error +} + +// policySetParameters implements Parameters. +type policySetParameters struct { + client *Client +} + +// PolicySetParameterList represents a list of parameters. +type PolicySetParameterList struct { + *Pagination + Items []*PolicySetParameter +} + +// PolicySetParameter represents a Policy Set parameter +type PolicySetParameter struct { + ID string `jsonapi:"primary,vars"` + Key string `jsonapi:"attr,key"` + Value string `jsonapi:"attr,value"` + Category CategoryType `jsonapi:"attr,category"` + Sensitive bool `jsonapi:"attr,sensitive"` + + // Relations + PolicySet *PolicySet `jsonapi:"relation,configurable"` +} + +// PolicySetParameterListOptions represents the options for listing parameters. +type PolicySetParameterListOptions struct { + ListOptions +} + +func (o PolicySetParameterListOptions) valid() error { + return nil +} + +// List all the parameters associated with the given policy-set. +func (s *policySetParameters) List(ctx context.Context, policySetID string, options PolicySetParameterListOptions) (*PolicySetParameterList, error) { + if !validStringID(&policySetID) { + return nil, errors.New("invalid value for policy set ID") + } + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("policy-sets/%s/parameters", policySetID) + req, err := s.client.newRequest("GET", u, &options) + if err != nil { + return nil, err + } + + vl := &PolicySetParameterList{} + err = s.client.do(ctx, req, vl) + if err != nil { + return nil, err + } + + return vl, nil +} + +// PolicySetParameterCreateOptions represents the options for creating a new parameter. +type PolicySetParameterCreateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,vars"` + + // The name of the parameter. + Key *string `jsonapi:"attr,key"` + + // The value of the parameter. + Value *string `jsonapi:"attr,value,omitempty"` + + // The Category of the parameter, should always be "policy-set" + Category *CategoryType `jsonapi:"attr,category"` + + // Whether the value is sensitive. + Sensitive *bool `jsonapi:"attr,sensitive,omitempty"` +} + +func (o PolicySetParameterCreateOptions) valid() error { + if !validString(o.Key) { + return errors.New("key is required") + } + if o.Category == nil { + return errors.New("category is required") + } + if *o.Category != CategoryPolicySet { + return errors.New("category must be policy-set") + } + return nil +} + +// Create is used to create a new parameter. +func (s *policySetParameters) Create(ctx context.Context, policySetID string, options PolicySetParameterCreateOptions) (*PolicySetParameter, error) { + if !validStringID(&policySetID) { + return nil, errors.New("invalid value for policy set ID") + } + if err := options.valid(); err != nil { + return nil, err + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("policy-sets/%s/parameters", url.QueryEscape(policySetID)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + p := &PolicySetParameter{} + err = s.client.do(ctx, req, p) + if err != nil { + return nil, err + } + + return p, nil +} + +// Read a parameter by its ID. +func (s *policySetParameters) Read(ctx context.Context, policySetID string, parameterID string) (*PolicySetParameter, error) { + if !validStringID(&policySetID) { + return nil, errors.New("invalid value for policy set ID") + } + if !validStringID(¶meterID) { + return nil, errors.New("invalid value for parameter ID") + } + + u := fmt.Sprintf("policy-sets/%s/parameters/%s", url.QueryEscape(policySetID), url.QueryEscape(parameterID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + p := &PolicySetParameter{} + err = s.client.do(ctx, req, p) + if err != nil { + return nil, err + } + + return p, err +} + +// PolicySetParameterUpdateOptions represents the options for updating a parameter. +type PolicySetParameterUpdateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,vars"` + + // The name of the parameter. + Key *string `jsonapi:"attr,key,omitempty"` + + // The value of the parameter. + Value *string `jsonapi:"attr,value,omitempty"` + + // Whether the value is sensitive. + Sensitive *bool `jsonapi:"attr,sensitive,omitempty"` +} + +// Update values of an existing parameter. +func (s *policySetParameters) Update(ctx context.Context, policySetID string, parameterID string, options PolicySetParameterUpdateOptions) (*PolicySetParameter, error) { + if !validStringID(&policySetID) { + return nil, errors.New("invalid value for policy set ID") + } + if !validStringID(¶meterID) { + return nil, errors.New("invalid value for parameter ID") + } + + // Make sure we don't send a user provided ID. + options.ID = parameterID + + u := fmt.Sprintf("policy-sets/%s/parameters/%s", url.QueryEscape(policySetID), url.QueryEscape(parameterID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + p := &PolicySetParameter{} + err = s.client.do(ctx, req, p) + if err != nil { + return nil, err + } + + return p, nil +} + +// Delete a parameter by its ID. +func (s *policySetParameters) Delete(ctx context.Context, policySetID string, parameterID string) error { + if !validStringID(&policySetID) { + return errors.New("invalid value for policy set ID") + } + if !validStringID(¶meterID) { + return errors.New("invalid value for parameter ID") + } + + u := fmt.Sprintf("policy-sets/%s/parameters/%s", url.QueryEscape(policySetID), url.QueryEscape(parameterID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/vendor/github.com/hashicorp/go-tfe/run.go b/vendor/github.com/hashicorp/go-tfe/run.go index 4517bf262..352ea64d2 100644 --- a/vendor/github.com/hashicorp/go-tfe/run.go +++ b/vendor/github.com/hashicorp/go-tfe/run.go @@ -98,6 +98,7 @@ type Run struct { Source RunSource `jsonapi:"attr,source"` Status RunStatus `jsonapi:"attr,status"` StatusTimestamps *RunStatusTimestamps `jsonapi:"attr,status-timestamps"` + TargetAddrs []string `jsonapi:"attr,target-addrs,omitempty"` // Relations Apply *Apply `jsonapi:"relation,apply"` @@ -184,6 +185,19 @@ type RunCreateOptions struct { // Specifies the workspace where the run will be executed. Workspace *Workspace `jsonapi:"relation,workspace"` + + // If non-empty, requests that Terraform should create a plan including + // actions only for the given objects (specified using resource address + // syntax) and the objects they depend on. + // + // This capability is provided for exceptional circumstances only, such as + // recovering from mistakes or working around existing Terraform + // limitations. Terraform will generally mention the -target command line + // option in its error messages describing situations where setting this + // argument may be appropriate. This argument should not be used as part + // of routine workflow and Terraform will emit warnings reminding about + // this whenever this property is set. + TargetAddrs []string `jsonapi:"attr,target-addrs,omitempty"` } func (o RunCreateOptions) valid() error { diff --git a/vendor/github.com/hashicorp/go-tfe/run_trigger.go b/vendor/github.com/hashicorp/go-tfe/run_trigger.go new file mode 100644 index 000000000..a4c6f4f65 --- /dev/null +++ b/vendor/github.com/hashicorp/go-tfe/run_trigger.go @@ -0,0 +1,177 @@ +package tfe + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ RunTriggers = (*runTriggers)(nil) + +// RunTriggers describes all the Run Trigger +// related methods that the Terraform Cloud API supports. +// +// TFE API docs: +// https://www.terraform.io/docs/cloud/api/run-triggers.html +type RunTriggers interface { + // List all the run triggers within a workspace. + List(ctx context.Context, workspaceID string, options RunTriggerListOptions) (*RunTriggerList, error) + + // Create a new run trigger with the given options. + Create(ctx context.Context, workspaceID string, options RunTriggerCreateOptions) (*RunTrigger, error) + + // Read a run trigger by its ID. + Read(ctx context.Context, RunTriggerID string) (*RunTrigger, error) + + // Delete a run trigger by its ID. + Delete(ctx context.Context, RunTriggerID string) error +} + +// runTriggers implements RunTriggers. +type runTriggers struct { + client *Client +} + +// RunTriggerList represents a list of Run Triggers +type RunTriggerList struct { + *Pagination + Items []*RunTrigger +} + +// RunTrigger represents a run trigger. +type RunTrigger struct { + ID string `jsonapi:"primary,run-triggers"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + SourceableName string `jsonapi:"attr,sourceable-name"` + WorkspaceName string `jsonapi:"attr,workspace-name"` + + // Relations + // TODO: this will eventually need to be polymorphic + Sourceable *Workspace `jsonapi:"relation,sourceable"` + Workspace *Workspace `jsonapi:"relation,workspace"` +} + +// RunTriggerListOptions represents the options for listing +// run triggers. +type RunTriggerListOptions struct { + ListOptions + RunTriggerType *string `url:"filter[run-trigger][type]"` +} + +func (o RunTriggerListOptions) valid() error { + if !validString(o.RunTriggerType) { + return errors.New("run-trigger type is required") + } + if *o.RunTriggerType != "inbound" && *o.RunTriggerType != "outbound" { + return errors.New("invalid value for run-trigger type") + } + return nil +} + +// List all the run triggers associated with a workspace. +func (s *runTriggers) List(ctx context.Context, workspaceID string, options RunTriggerListOptions) (*RunTriggerList, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("workspaces/%s/run-triggers", url.QueryEscape(workspaceID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + rtl := &RunTriggerList{} + err = s.client.do(ctx, req, rtl) + if err != nil { + return nil, err + } + + return rtl, nil +} + +// RunTriggerCreateOptions represents the options for +// creating a new run trigger. +type RunTriggerCreateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,run-triggers"` + + // The source workspace + Sourceable *Workspace `jsonapi:"relation,sourceable"` +} + +func (o RunTriggerCreateOptions) valid() error { + if o.Sourceable == nil { + return errors.New("sourceable is required") + } + return nil +} + +// Creates a run trigger with the given options. +func (s *runTriggers) Create(ctx context.Context, workspaceID string, options RunTriggerCreateOptions) (*RunTrigger, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") + } + if err := options.valid(); err != nil { + return nil, err + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("workspaces/%s/run-triggers", url.QueryEscape(workspaceID)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + rt := &RunTrigger{} + err = s.client.do(ctx, req, rt) + if err != nil { + return nil, err + } + + return rt, nil +} + +// Read a run trigger by its ID. +func (s *runTriggers) Read(ctx context.Context, runTriggerID string) (*RunTrigger, error) { + if !validStringID(&runTriggerID) { + return nil, errors.New("invalid value for run trigger ID") + } + + u := fmt.Sprintf("run-triggers/%s", url.QueryEscape(runTriggerID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + rt := &RunTrigger{} + err = s.client.do(ctx, req, rt) + if err != nil { + return nil, err + } + + return rt, nil +} + +// Delete a run trigger by its ID. +func (s *runTriggers) Delete(ctx context.Context, runTriggerID string) error { + if !validStringID(&runTriggerID) { + return errors.New("invalid value for run trigger ID") + } + + u := fmt.Sprintf("run-triggers/%s", url.QueryEscape(runTriggerID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/vendor/github.com/hashicorp/go-tfe/team.go b/vendor/github.com/hashicorp/go-tfe/team.go index 7bd93a4f9..3828c5c76 100644 --- a/vendor/github.com/hashicorp/go-tfe/team.go +++ b/vendor/github.com/hashicorp/go-tfe/team.go @@ -47,11 +47,13 @@ type Team struct { ID string `jsonapi:"primary,teams"` Name string `jsonapi:"attr,name"` OrganizationAccess *OrganizationAccess `jsonapi:"attr,organization-access"` + Visibility string `jsonapi:"attr,visibility"` Permissions *TeamPermissions `jsonapi:"attr,permissions"` UserCount int `jsonapi:"attr,users-count"` // Relations - Users []*User `jsonapi:"relation,users"` + Users []*User `jsonapi:"relation,users"` + OrganizationMemberships []*OrganizationMembership `jsonapi:"relation,organization-memberships"` } // OrganizationAccess represents the team's permissions on its organization @@ -103,6 +105,9 @@ type TeamCreateOptions struct { // The team's organization access OrganizationAccess *OrganizationAccessOptions `jsonapi:"attr,organization-access,omitempty"` + + // The team's visibility ("secret", "organization") + Visibility *string `jsonapi:"attr,visibility,omitempty"` } // OrganizationAccessOptions represents the organization access options of a team. @@ -177,6 +182,9 @@ type TeamUpdateOptions struct { // The team's organization access OrganizationAccess *OrganizationAccessOptions `jsonapi:"attr,organization-access,omitempty"` + + // The team's visibility ("secret", "organization") + Visibility *string `jsonapi:"attr,visibility,omitempty"` } // Update a team by its ID. diff --git a/vendor/github.com/hashicorp/go-tfe/team_member.go b/vendor/github.com/hashicorp/go-tfe/team_member.go index 8ab97fdac..450e57472 100644 --- a/vendor/github.com/hashicorp/go-tfe/team_member.go +++ b/vendor/github.com/hashicorp/go-tfe/team_member.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/url" + + retryablehttp "github.com/hashicorp/go-retryablehttp" ) // Compile-time proof of interface implementation. @@ -16,9 +18,16 @@ var _ TeamMembers = (*teamMembers)(nil) // TFE API docs: // https://www.terraform.io/docs/enterprise/api/team-members.html type TeamMembers interface { - // List all members of a team. + // List returns all Users of a team calling ListUsers + // See ListOrganizationMemberships for fetching memberships List(ctx context.Context, teamID string) ([]*User, error) + // ListUsers returns the Users of this team. + ListUsers(ctx context.Context, teamID string) ([]*User, error) + + // ListOrganizationMemberships returns the OrganizationMemberships of this team. + ListOrganizationMemberships(ctx context.Context, teamID string) ([]*OrganizationMembership, error) + // Add multiple users to a team. Add(ctx context.Context, teamID string, options TeamMemberAddOptions) error @@ -31,12 +40,22 @@ type teamMembers struct { client *Client } -type teamMember struct { +type teamMemberUser struct { Username string `jsonapi:"primary,users"` } -// List all members of a team. +type teamMemberOrgMembership struct { + ID string `jsonapi:"primary,organization-memberships"` +} + +// List returns all Users of a team calling ListUsers +// See ListOrganizationMemberships for fetching memberships func (s *teamMembers) List(ctx context.Context, teamID string) ([]*User, error) { + return s.ListUsers(ctx, teamID) +} + +// ListUsers returns the Users of this team. +func (s *teamMembers) ListUsers(ctx context.Context, teamID string) ([]*User, error) { if !validStringID(&teamID) { return nil, errors.New("invalid value for team ID") } @@ -62,21 +81,65 @@ func (s *teamMembers) List(ctx context.Context, teamID string) ([]*User, error) return t.Users, nil } -// TeamMemberAddOptions represents the options for adding team members. +// ListOrganizationMemberships returns the OrganizationMemberships of this team. +func (s *teamMembers) ListOrganizationMemberships(ctx context.Context, teamID string) ([]*OrganizationMembership, error) { + if !validStringID(&teamID) { + return nil, errors.New("invalid value for team ID") + } + + options := struct { + Include string `url:"include"` + }{ + Include: "organization-memberships", + } + + u := fmt.Sprintf("teams/%s", url.QueryEscape(teamID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + t := &Team{} + err = s.client.do(ctx, req, t) + if err != nil { + return nil, err + } + + return t.OrganizationMemberships, nil +} + +// TeamMemberAddOptions represents the options for +// adding or removing team members. type TeamMemberAddOptions struct { - Usernames []string + Usernames []string + OrganizationMembershipIDs []string } func (o *TeamMemberAddOptions) valid() error { - if o.Usernames == nil { - return errors.New("usernames is required") + if o.Usernames == nil && o.OrganizationMembershipIDs == nil { + return errors.New("usernames or organization membership ids are required") } - if len(o.Usernames) == 0 { + if o.Usernames != nil && o.OrganizationMembershipIDs != nil { + return errors.New("only one of usernames or organization membership ids can be provided") + } + if o.Usernames != nil && len(o.Usernames) == 0 { return errors.New("invalid value for usernames") } + if o.OrganizationMembershipIDs != nil && len(o.OrganizationMembershipIDs) == 0 { + return errors.New("invalid value for organization membership ids") + } return nil } +// kind returns "users" or "organization-memberships" +// depending on which is defined +func (o *TeamMemberAddOptions) kind() string { + if o.Usernames != nil && len(o.Usernames) != 0 { + return "users" + } + return "organization-memberships" +} + // Add multiple users to a team. func (s *teamMembers) Add(ctx context.Context, teamID string, options TeamMemberAddOptions) error { if !validStringID(&teamID) { @@ -86,35 +149,68 @@ func (s *teamMembers) Add(ctx context.Context, teamID string, options TeamMember return err } - var tms []*teamMember - for _, name := range options.Usernames { - tms = append(tms, &teamMember{Username: name}) - } + usersOrMemberships := options.kind() + URL := fmt.Sprintf("teams/%s/relationships/%s", url.QueryEscape(teamID), usersOrMemberships) - u := fmt.Sprintf("teams/%s/relationships/users", url.QueryEscape(teamID)) - req, err := s.client.newRequest("POST", u, tms) - if err != nil { - return err + var req *retryablehttp.Request + + if usersOrMemberships == "users" { + var err error + var members []*teamMemberUser + for _, name := range options.Usernames { + members = append(members, &teamMemberUser{Username: name}) + } + req, err = s.client.newRequest("POST", URL, members) + if err != nil { + return err + } + } else { + var err error + var members []*teamMemberOrgMembership + for _, ID := range options.OrganizationMembershipIDs { + members = append(members, &teamMemberOrgMembership{ID: ID}) + } + req, err = s.client.newRequest("POST", URL, members) + if err != nil { + return err + } } return s.client.do(ctx, req, nil) } -// TeamMemberRemoveOptions represents the options for deleting team members. +// TeamMemberRemoveOptions represents the options for +// adding or removing team members. type TeamMemberRemoveOptions struct { - Usernames []string + Usernames []string + OrganizationMembershipIDs []string } func (o *TeamMemberRemoveOptions) valid() error { - if o.Usernames == nil { - return errors.New("usernames is required") + if o.Usernames == nil && o.OrganizationMembershipIDs == nil { + return errors.New("usernames or organization membership ids are required") } - if len(o.Usernames) == 0 { + if o.Usernames != nil && o.OrganizationMembershipIDs != nil { + return errors.New("only one of usernames or organization membership ids can be provided") + } + if o.Usernames != nil && len(o.Usernames) == 0 { return errors.New("invalid value for usernames") } + if o.OrganizationMembershipIDs != nil && len(o.OrganizationMembershipIDs) == 0 { + return errors.New("invalid value for organization membership ids") + } return nil } +// kind returns "users" or "organization-memberships" +// depending on which is defined +func (o *TeamMemberRemoveOptions) kind() string { + if o.Usernames != nil && len(o.Usernames) != 0 { + return "users" + } + return "organization-memberships" +} + // Remove multiple users from a team. func (s *teamMembers) Remove(ctx context.Context, teamID string, options TeamMemberRemoveOptions) error { if !validStringID(&teamID) { @@ -124,15 +220,31 @@ func (s *teamMembers) Remove(ctx context.Context, teamID string, options TeamMem return err } - var tms []*teamMember - for _, name := range options.Usernames { - tms = append(tms, &teamMember{Username: name}) - } + usersOrMemberships := options.kind() + URL := fmt.Sprintf("teams/%s/relationships/%s", url.QueryEscape(teamID), usersOrMemberships) - u := fmt.Sprintf("teams/%s/relationships/users", url.QueryEscape(teamID)) - req, err := s.client.newRequest("DELETE", u, tms) - if err != nil { - return err + var req *retryablehttp.Request + + if usersOrMemberships == "users" { + var err error + var members []*teamMemberUser + for _, name := range options.Usernames { + members = append(members, &teamMemberUser{Username: name}) + } + req, err = s.client.newRequest("DELETE", URL, members) + if err != nil { + return err + } + } else { + var err error + var members []*teamMemberOrgMembership + for _, ID := range options.OrganizationMembershipIDs { + members = append(members, &teamMemberOrgMembership{ID: ID}) + } + req, err = s.client.newRequest("DELETE", URL, members) + if err != nil { + return err + } } return s.client.do(ctx, req, nil) diff --git a/vendor/github.com/hashicorp/go-tfe/testing.go b/vendor/github.com/hashicorp/go-tfe/testing.go new file mode 100644 index 000000000..79f9c218a --- /dev/null +++ b/vendor/github.com/hashicorp/go-tfe/testing.go @@ -0,0 +1,36 @@ +package tfe + +import ( + "context" + "testing" +) + +// TestAccountDetails represents the basic account information +// of a TFE/TFC user. +// +// See FetchTestAccountDetails for more information. +type TestAccountDetails struct { + ID string `json:"id" jsonapi:"primary,users"` + Username string `jsonapi:"attr,username"` + Email string `jsonapi:"attr,email"` +} + +// FetchTestAccountDetails returns TestAccountDetails +// of the user running the tests. +// +// Use this helper to fetch the username and email +// address associated with the token used to run the tests. +func FetchTestAccountDetails(t *testing.T, client *Client) *TestAccountDetails { + tad := &TestAccountDetails{} + req, err := client.newRequest("GET", "account/details", nil) + if err != nil { + t.Fatalf("could not create account details request: %v", err) + } + + ctx := context.Background() + err = client.do(ctx, req, tad) + if err != nil { + t.Fatalf("could not fetch test user details: %v", err) + } + return tad +} diff --git a/vendor/github.com/hashicorp/go-tfe/tfe.go b/vendor/github.com/hashicorp/go-tfe/tfe.go index da3435a65..5883d9d87 100644 --- a/vendor/github.com/hashicorp/go-tfe/tfe.go +++ b/vendor/github.com/hashicorp/go-tfe/tfe.go @@ -24,15 +24,16 @@ import ( ) const ( - userAgent = "go-tfe" - headerRateLimit = "X-RateLimit-Limit" - headerRateReset = "X-RateLimit-Reset" + userAgent = "go-tfe" + headerRateLimit = "X-RateLimit-Limit" + headerRateReset = "X-RateLimit-Reset" + headerAPIVersion = "TFP-API-Version" // DefaultAddress of Terraform Enterprise. DefaultAddress = "https://app.terraform.io" // DefaultBasePath on which the API is served. DefaultBasePath = "/api/v2/" - // No-op API endpoint used to configure the rate limiter + // PingEndpoint is a no-op API endpoint used to configure the rate limiter PingEndpoint = "ping" ) @@ -105,6 +106,7 @@ type Client struct { limiter *rate.Limiter retryLogHook RetryLogHook retryServerErrors bool + remoteAPIVersion string Applies Applies ConfigurationVersions ConfigurationVersions @@ -113,13 +115,16 @@ type Client struct { OAuthClients OAuthClients OAuthTokens OAuthTokens Organizations Organizations + OrganizationMemberships OrganizationMemberships OrganizationTokens OrganizationTokens Plans Plans PlanExports PlanExports Policies Policies PolicyChecks PolicyChecks + PolicySetParameters PolicySetParameters PolicySets PolicySets Runs Runs + RunTriggers RunTriggers SSHKeys SSHKeys StateVersions StateVersions Teams Teams @@ -191,11 +196,18 @@ func NewClient(cfg *Config) (*Client, error) { RetryMax: 30, } - // Configure the rate limiter. - if err := client.configureLimiter(); err != nil { + meta, err := client.getRawAPIMetadata() + if err != nil { return nil, err } + // Configure the rate limiter. + client.configureLimiter(meta.RateLimit) + + // Save the API version so we can return it from the RemoteAPIVersion + // method later. + client.remoteAPIVersion = meta.APIVersion + // Create the services. client.Applies = &applies{client: client} client.ConfigurationVersions = &configurationVersions{client: client} @@ -204,13 +216,16 @@ func NewClient(cfg *Config) (*Client, error) { client.OAuthClients = &oAuthClients{client: client} client.OAuthTokens = &oAuthTokens{client: client} client.Organizations = &organizations{client: client} + client.OrganizationMemberships = &organizationMemberships{client: client} client.OrganizationTokens = &organizationTokens{client: client} client.Plans = &plans{client: client} client.PlanExports = &planExports{client: client} client.Policies = &policies{client: client} client.PolicyChecks = &policyChecks{client: client} + client.PolicySetParameters = &policySetParameters{client: client} client.PolicySets = &policySets{client: client} client.Runs = &runs{client: client} + client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.StateVersions = &stateVersions{client: client} client.Teams = &teams{client: client} @@ -224,6 +239,26 @@ func NewClient(cfg *Config) (*Client, error) { return client, nil } +// RemoteAPIVersion returns the server's declared API version string. +// +// A Terraform Cloud or Enterprise API server returns its API version in an +// HTTP header field in all responses. The NewClient function saves the +// version number returned in its initial setup request and RemoteAPIVersion +// returns that cached value. +// +// The API protocol calls for this string to be a dotted-decimal version number +// like 2.3.0, where the first number indicates the API major version while the +// second indicates a minor version which may have introduced some +// backward-compatible additional features compared to its predecessor. +// +// Explicit API versioning was added to the Terraform Cloud and Enterprise +// APIs as a later addition, so older servers will not return version +// information. In that case, this function returns an empty string as the +// version. +func (c *Client) RemoteAPIVersion() string { + return c.remoteAPIVersion +} + // RetryServerErrors configures the retry HTTP check to also retry // unexpected errors or requests that failed with a server error. func (c *Client) RetryServerErrors(retry bool) { @@ -292,16 +327,29 @@ func rateLimitBackoff(min, max time.Duration, attemptNum int, resp *http.Respons return min + jitter } -// configureLimiter configures the rate limiter. -func (c *Client) configureLimiter() error { +type rawAPIMetadata struct { + // APIVersion is the raw API version string reported by the server in the + // TFP-API-Version response header, or an empty string if that header + // field was not included in the response. + APIVersion string + + // RateLimit is the raw API version string reported by the server in the + // X-RateLimit-Limit response header, or an empty string if that header + // field was not included in the response. + RateLimit string +} + +func (c *Client) getRawAPIMetadata() (rawAPIMetadata, error) { + var meta rawAPIMetadata + // Create a new request. u, err := c.baseURL.Parse(PingEndpoint) if err != nil { - return err + return meta, err } req, err := http.NewRequest("GET", u.String(), nil) if err != nil { - return err + return meta, err } // Attach the default headers. @@ -314,15 +362,24 @@ func (c *Client) configureLimiter() error { // Make a single request to retrieve the rate limit headers. resp, err := c.http.HTTPClient.Do(req) if err != nil { - return err + return meta, err } resp.Body.Close() + meta.APIVersion = resp.Header.Get(headerAPIVersion) + meta.RateLimit = resp.Header.Get(headerRateLimit) + + return meta, nil +} + +// configureLimiter configures the rate limiter. +func (c *Client) configureLimiter(rawLimit string) { + // Set default values for when rate limiting is disabled. limit := rate.Inf burst := 0 - if v := resp.Header.Get(headerRateLimit); v != "" { + if v := rawLimit; v != "" { if rateLimit, _ := strconv.ParseFloat(v, 64); rateLimit > 0 { // Configure the limit and burst using a split of 2/3 for the limit and // 1/3 for the burst. This enables clients to burst 1/3 of the allowed @@ -336,8 +393,6 @@ func (c *Client) configureLimiter() error { // Create a new limiter using the calculated values. c.limiter = rate.NewLimiter(limit, burst) - - return nil } // newRequest creates an API request. A relative URL path can be provided in diff --git a/vendor/github.com/hashicorp/go-tfe/variable.go b/vendor/github.com/hashicorp/go-tfe/variable.go index eb3dbc6e5..6ac8b5969 100644 --- a/vendor/github.com/hashicorp/go-tfe/variable.go +++ b/vendor/github.com/hashicorp/go-tfe/variable.go @@ -16,19 +16,19 @@ var _ Variables = (*variables)(nil) // TFE API docs: https://www.terraform.io/docs/enterprise/api/variables.html type Variables interface { // List all the variables associated with the given workspace. - List(ctx context.Context, options VariableListOptions) (*VariableList, error) + List(ctx context.Context, workspaceID string, options VariableListOptions) (*VariableList, error) // Create is used to create a new variable. - Create(ctx context.Context, options VariableCreateOptions) (*Variable, error) + Create(ctx context.Context, workspaceID string, options VariableCreateOptions) (*Variable, error) // Read a variable by its ID. - Read(ctx context.Context, variableID string) (*Variable, error) + Read(ctx context.Context, workspaceID string, variableID string) (*Variable, error) // Update values of an existing variable. - Update(ctx context.Context, variableID string, options VariableUpdateOptions) (*Variable, error) + Update(ctx context.Context, workspaceID string, variableID string, options VariableUpdateOptions) (*Variable, error) // Delete a variable by its ID. - Delete(ctx context.Context, variableID string) error + Delete(ctx context.Context, workspaceID string, variableID string) error } // variables implements Variables. @@ -42,6 +42,7 @@ type CategoryType string //List all available categories. const ( CategoryEnv CategoryType = "env" + CategoryPolicySet CategoryType = "policy-set" CategoryTerraform CategoryType = "terraform" ) @@ -56,38 +57,28 @@ type Variable struct { ID string `jsonapi:"primary,vars"` Key string `jsonapi:"attr,key"` Value string `jsonapi:"attr,value"` + Description string `jsonapi:"attr,description"` Category CategoryType `jsonapi:"attr,category"` HCL bool `jsonapi:"attr,hcl"` Sensitive bool `jsonapi:"attr,sensitive"` // Relations - Workspace *Workspace `jsonapi:"relation,workspace"` + Workspace *Workspace `jsonapi:"relation,configurable"` } // VariableListOptions represents the options for listing variables. type VariableListOptions struct { ListOptions - Organization *string `url:"filter[organization][name]"` - Workspace *string `url:"filter[workspace][name]"` -} - -func (o VariableListOptions) valid() error { - if !validString(o.Organization) { - return errors.New("organization is required") - } - if !validString(o.Workspace) { - return errors.New("workspace is required") - } - return nil } // List all the variables associated with the given workspace. -func (s *variables) List(ctx context.Context, options VariableListOptions) (*VariableList, error) { - if err := options.valid(); err != nil { - return nil, err +func (s *variables) List(ctx context.Context, workspaceID string, options VariableListOptions) (*VariableList, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") } - req, err := s.client.newRequest("GET", "vars", &options) + u := fmt.Sprintf("workspaces/%s/vars", workspaceID) + req, err := s.client.newRequest("GET", u, &options) if err != nil { return nil, err } @@ -112,6 +103,9 @@ type VariableCreateOptions struct { // The value of the variable. Value *string `jsonapi:"attr,value,omitempty"` + // The description of the variable. + Description *string `jsonapi:"attr,description,omitempty"` + // Whether this is a Terraform or environment variable. Category *CategoryType `jsonapi:"attr,category"` @@ -120,9 +114,6 @@ type VariableCreateOptions struct { // Whether the value is sensitive. Sensitive *bool `jsonapi:"attr,sensitive,omitempty"` - - // The workspace that owns the variable. - Workspace *Workspace `jsonapi:"relation,workspace"` } func (o VariableCreateOptions) valid() error { @@ -132,14 +123,14 @@ func (o VariableCreateOptions) valid() error { if o.Category == nil { return errors.New("category is required") } - if o.Workspace == nil { - return errors.New("workspace is required") - } return nil } // Create is used to create a new variable. -func (s *variables) Create(ctx context.Context, options VariableCreateOptions) (*Variable, error) { +func (s *variables) Create(ctx context.Context, workspaceID string, options VariableCreateOptions) (*Variable, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") + } if err := options.valid(); err != nil { return nil, err } @@ -147,7 +138,8 @@ func (s *variables) Create(ctx context.Context, options VariableCreateOptions) ( // Make sure we don't send a user provided ID. options.ID = "" - req, err := s.client.newRequest("POST", "vars", &options) + u := fmt.Sprintf("workspaces/%s/vars", url.QueryEscape(workspaceID)) + req, err := s.client.newRequest("POST", u, &options) if err != nil { return nil, err } @@ -162,12 +154,15 @@ func (s *variables) Create(ctx context.Context, options VariableCreateOptions) ( } // Read a variable by its ID. -func (s *variables) Read(ctx context.Context, variableID string) (*Variable, error) { +func (s *variables) Read(ctx context.Context, workspaceID string, variableID string) (*Variable, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") + } if !validStringID(&variableID) { return nil, errors.New("invalid value for variable ID") } - u := fmt.Sprintf("vars/%s", url.QueryEscape(variableID)) + u := fmt.Sprintf("workspaces/%s/vars/%s", url.QueryEscape(workspaceID), url.QueryEscape(variableID)) req, err := s.client.newRequest("GET", u, nil) if err != nil { return nil, err @@ -193,6 +188,9 @@ type VariableUpdateOptions struct { // The value of the variable. Value *string `jsonapi:"attr,value,omitempty"` + // The description of the variable. + Description *string `jsonapi:"attr,description,omitempty"` + // Whether to evaluate the value of the variable as a string of HCL code. HCL *bool `jsonapi:"attr,hcl,omitempty"` @@ -201,7 +199,10 @@ type VariableUpdateOptions struct { } // Update values of an existing variable. -func (s *variables) Update(ctx context.Context, variableID string, options VariableUpdateOptions) (*Variable, error) { +func (s *variables) Update(ctx context.Context, workspaceID string, variableID string, options VariableUpdateOptions) (*Variable, error) { + if !validStringID(&workspaceID) { + return nil, errors.New("invalid value for workspace ID") + } if !validStringID(&variableID) { return nil, errors.New("invalid value for variable ID") } @@ -209,7 +210,7 @@ func (s *variables) Update(ctx context.Context, variableID string, options Varia // Make sure we don't send a user provided ID. options.ID = variableID - u := fmt.Sprintf("vars/%s", url.QueryEscape(variableID)) + u := fmt.Sprintf("workspaces/%s/vars/%s", url.QueryEscape(workspaceID), url.QueryEscape(variableID)) req, err := s.client.newRequest("PATCH", u, &options) if err != nil { return nil, err @@ -225,12 +226,15 @@ func (s *variables) Update(ctx context.Context, variableID string, options Varia } // Delete a variable by its ID. -func (s *variables) Delete(ctx context.Context, variableID string) error { +func (s *variables) Delete(ctx context.Context, workspaceID string, variableID string) error { + if !validStringID(&workspaceID) { + return errors.New("invalid value for workspace ID") + } if !validStringID(&variableID) { return errors.New("invalid value for variable ID") } - u := fmt.Sprintf("vars/%s", url.QueryEscape(variableID)) + u := fmt.Sprintf("workspaces/%s/vars/%s", url.QueryEscape(workspaceID), url.QueryEscape(variableID)) req, err := s.client.newRequest("DELETE", u, nil) if err != nil { return err diff --git a/vendor/modules.txt b/vendor/modules.txt index 1e6d6da77..c092a7995 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -348,7 +348,7 @@ github.com/hashicorp/go-safetemp github.com/hashicorp/go-slug # github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 ## explicit -# github.com/hashicorp/go-tfe v0.3.27 +# github.com/hashicorp/go-tfe v0.8.0 ## explicit github.com/hashicorp/go-tfe # github.com/hashicorp/go-uuid v1.0.1 From 16f1f3b739c99923601594f5a36695c3e821ad10 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 1 May 2020 16:01:36 -0700 Subject: [PATCH 2/5] backend/remote: Support -target on plan and apply Previously we did not allow -target to be used with the remote backend because there was no way to send the targets to Terraform Cloud/Enterprise via the API. There is now an attribute in the request for creating a plan that allows us to send target addresses, so we'll remove that restriction and copy the given target addresses into the API request. --- backend/remote/backend_apply.go | 8 -------- backend/remote/backend_apply_test.go | 22 +++++++++++++++------- backend/remote/backend_mock.go | 1 + backend/remote/backend_plan.go | 19 +++++++++++-------- backend/remote/backend_plan_test.go | 22 +++++++++++++++------- 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/backend/remote/backend_apply.go b/backend/remote/backend_apply.go index ef5b1511c..0dd78c6ce 100644 --- a/backend/remote/backend_apply.go +++ b/backend/remote/backend_apply.go @@ -67,14 +67,6 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati )) } - if op.Targets != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Resource targeting is currently not supported", - `The "remote" backend does not support resource targeting at this time.`, - )) - } - if b.hasExplicitVariableValues(op) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/backend/remote/backend_apply_test.go b/backend/remote/backend_apply_test.go index a8d2ef70c..87a1cbc72 100644 --- a/backend/remote/backend_apply_test.go +++ b/backend/remote/backend_apply_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" @@ -278,16 +279,23 @@ func TestRemote_applyWithTarget(t *testing.T) { } <-run.Done() - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") + if run.Result != backend.OperationSuccess { + t.Fatal("expected apply operation to succeed") } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") } - errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() - if !strings.Contains(errOutput, "targeting is currently not supported") { - t.Fatalf("expected a targeting error, got: %v", errOutput) + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } } } diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index d1e7e23e2..0a57801e8 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -757,6 +757,7 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t Permissions: &tfe.RunPermissions{}, Plan: p, Status: tfe.RunPending, + TargetAddrs: options.TargetAddrs, } if pc != nil { diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index c83d91bc4..8b37f62e1 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -70,14 +70,6 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } - if op.Targets != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Resource targeting is currently not supported", - `The "remote" backend does not support resource targeting at this time.`, - )) - } - if b.hasExplicitVariableValues(op) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -224,6 +216,17 @@ in order to capture the filesystem context the remote workspace expects: Workspace: w, } + if len(op.Targets) != 0 { + runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) + for _, addr := range op.Targets { + // The API client wants the normal string representation of a + // target address, which will ultimately get inserted into a + // -target option when Terraform CLI is launched in the + // Cloud/Enterprise execution environment. + runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String()) + } + } + r, err := b.client.Runs.Create(stopCtx, runOptions) if err != nil { return r, generalError("Failed to create run", err) diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index 767b501d4..7ea84694b 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" @@ -283,16 +284,23 @@ func TestRemote_planWithTarget(t *testing.T) { } <-run.Done() - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") } - errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() - if !strings.Contains(errOutput, "targeting is currently not supported") { - t.Fatalf("expected a targeting error, got: %v", errOutput) + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } } } From 650a272a1d1e04e343045982bdb9b98993c6caea Mon Sep 17 00:00:00 2001 From: Mike Nomitch Date: Wed, 13 May 2020 10:05:22 -0500 Subject: [PATCH 3/5] Change message sent to remote backend if -target used --- backend/remote/backend_mock.go | 4 ++++ backend/remote/backend_plan.go | 7 ++++++- backend/remote/backend_plan_test.go | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index 0a57801e8..a856b0612 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -760,6 +760,10 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t TargetAddrs: options.TargetAddrs, } + if options.Message != nil { + r.Message = *options.Message + } + if pc != nil { r.PolicyChecks = []*tfe.PolicyCheck{pc} } diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 8b37f62e1..957b28aea 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -209,9 +209,14 @@ in order to capture the filesystem context the remote workspace expects: "Failed to upload configuration files", errors.New("operation timed out")) } + queueMessage := "Queued manually using Terraform" + if op.Targets != nil { + queueMessage = "Queued manually via Terraform using -target" + } + runOptions := tfe.RunCreateOptions{ IsDestroy: tfe.Bool(op.Destroy), - Message: tfe.String("Queued manually using Terraform"), + Message: tfe.String(queueMessage), ConfigurationVersion: cv, Workspace: w, } diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index 7ea84694b..89cdc1ff1 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -301,6 +301,10 @@ func TestRemote_planWithTarget(t *testing.T) { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } + + if !strings.Contains(run.Message, "using -target") { + t.Fatalf("incorrrect Message on the created run: %s", run.Message) + } } } From 0eea4e7c6226307c2494fb90fff4a814ea0c355f Mon Sep 17 00:00:00 2001 From: CJ Horton <17039873+radditude@users.noreply.github.com> Date: Thu, 14 May 2020 20:01:55 -0700 Subject: [PATCH 4/5] prevent targeting for unsupported API versions --- backend/remote/backend_apply.go | 21 +++++++++++++++++++++ backend/remote/backend_plan.go | 21 +++++++++++++++++++++ backend/remote/testing.go | 6 ++++++ 3 files changed, 48 insertions(+) diff --git a/backend/remote/backend_apply.go b/backend/remote/backend_apply.go index 0dd78c6ce..5db412baa 100644 --- a/backend/remote/backend_apply.go +++ b/backend/remote/backend_apply.go @@ -8,6 +8,7 @@ import ( "log" tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -94,6 +95,26 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati )) } + if len(op.Targets) != 0 { + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + desiredAPIVersion, _ := version.NewVersion("2.3") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is not supported", + fmt.Sprintf( + `The host %s does not support the -target option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 957b28aea..f9fcf82b6 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -15,6 +15,7 @@ import ( "time" tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/tfdiags" ) @@ -98,6 +99,26 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } + if len(op.Targets) != 0 { + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + desiredAPIVersion, _ := version.NewVersion("2.3") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is not supported", + fmt.Sprintf( + `The host %s does not support the -target option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/backend/remote/testing.go b/backend/remote/testing.go index e506f28f9..3afaf97aa 100644 --- a/backend/remote/testing.go +++ b/backend/remote/testing.go @@ -207,6 +207,12 @@ func testServer(t *testing.T) *httptest.Server { }`, path.Base(r.URL.Path))) }) + // Respond to pings to get the API version header. + mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.3") + }) + // Respond to the initial query to read the hashicorp org entitlements. mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.api+json") From 8e1615a802a07e95b9d7b10618d7db9ff9ff6027 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 18 May 2020 15:12:44 -0700 Subject: [PATCH 5/5] backend/remote: Handle cost estimation skipped due to targeting The remote server might choose to skip running cost estimation for a targeted plan, in which case we'll show a note about it in the UI and then move on, rather than returning an "invalid status" error. This new status isn't yet available in the go-tfe library as a constant, so for now we have the string directly in our switch statement. This is a pragmatic way to expedite getting the "critical path" of this feature in place without blocking on changes to ancillary codebases. A subsequent commit should switch this over to tfe.CostEstimateSkippedDueToTargeting once that's available in a go-tfe release. --- backend/remote/backend_common.go | 4 ++++ backend/remote/backend_mock.go | 11 ++++++++++ backend/remote/backend_plan_test.go | 32 ++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/remote/backend_common.go b/backend/remote/backend_common.go index c92b80e92..b83b9d829 100644 --- a/backend/remote/backend_common.go +++ b/backend/remote/backend_common.go @@ -316,6 +316,10 @@ func (b *Remote) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Op b.CLI.Output(b.Colorize().Color("Waiting for cost estimate to complete..." + elapsed + "\n")) } continue + case "skipped_due_to_targeting": // TEMP: not available in the go-tfe library yet; will update this to be tfe.CostEstimateSkippedDueToTargeting once that's available. + b.CLI.Output("Not available for this plan, because it was created with the -target option.") + b.CLI.Output("\n------------------------------------------------------------------------") + return nil case tfe.CostEstimateErrored: return fmt.Errorf(msgPrefix + " errored.") case tfe.CostEstimateCanceled: diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index a856b0612..9b9b51126 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -696,6 +696,11 @@ type mockRuns struct { client *mockClient runs map[string]*tfe.Run workspaces map[string][]*tfe.Run + + // If modifyNewRun is non-nil, the create method will call it just before + // saving a new run in the runs map, so that a calling test can mimic + // side-effects that a real server might apply in certain situations. + modifyNewRun func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) } func newMockRuns(client *mockClient) *mockRuns { @@ -780,6 +785,12 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t w.CurrentRun = r } + if m.modifyNewRun != nil { + // caller-provided callback may modify the run in-place to mimic + // side-effects that a real server might take in some situations. + m.modifyNewRun(m.client, options, r) + } + m.runs[r.ID] = r m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index 89cdc1ff1..dd247e0c6 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -270,6 +270,29 @@ func TestRemote_planWithTarget(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() + // When the backend code creates a new run, we'll tweak it so that it + // has a cost estimation object with the "skipped_due_to_targeting" status, + // emulating how a real server is expected to behave in that case. + b.client.Runs.(*mockRuns).modifyNewRun = func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) { + const fakeID = "fake" + // This is the cost estimate object embedded in the run itself which + // the backend will use to learn the ID to request from the cost + // estimates endpoint. It's pending to simulate what a freshly-created + // run is likely to look like. + run.CostEstimate = &tfe.CostEstimate{ + ID: fakeID, + Status: "pending", + } + // The backend will then use the main cost estimation API to retrieve + // the same ID indicated in the object above, where we'll then return + // the status "skipped_due_to_targeting" to trigger the special skip + // message in the backend output. + client.CostEstimates.estimations[fakeID] = &tfe.CostEstimate{ + ID: fakeID, + Status: "skipped_due_to_targeting", + } + } + op, configCleanup := testOperationPlan(t, "./testdata/plan") defer configCleanup() @@ -291,6 +314,13 @@ func TestRemote_planWithTarget(t *testing.T) { t.Fatalf("expected plan to be non-empty") } + // testBackendDefault above attached a "mock UI" to our backend, so we + // can retrieve its non-error output via the OutputWriter in-memory buffer. + gotOutput := b.CLI.(*cli.MockUi).OutputWriter.String() + if wantOutput := "Not available for this plan, because it was created with the -target option."; !strings.Contains(gotOutput, wantOutput) { + t.Errorf("missing message about skipped cost estimation\ngot:\n%s\nwant substring: %s", gotOutput, wantOutput) + } + // We should find a run inside the mock client that has the same // target address we requested above. runsAPI := b.client.Runs.(*mockRuns) @@ -303,7 +333,7 @@ func TestRemote_planWithTarget(t *testing.T) { } if !strings.Contains(run.Message, "using -target") { - t.Fatalf("incorrrect Message on the created run: %s", run.Message) + t.Errorf("incorrect Message on the created run: %s", run.Message) } } }