backend/remote: Support -target on plan and apply

This commit is contained in:
Martin Atkins 2020-05-19 09:24:19 -07:00 committed by GitHub
commit c1f69fba03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1132 additions and 151 deletions

View File

@ -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"
@ -67,14 +68,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,
@ -102,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()

View File

@ -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)
}
}
}

View File

@ -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:

View File

@ -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))

View File

@ -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)

View File

@ -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 {
@ -757,6 +762,11 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
Permissions: &tfe.RunPermissions{},
Plan: p,
Status: tfe.RunPending,
TargetAddrs: options.TargetAddrs,
}
if options.Message != nil {
r.Message = *options.Message
}
if pc != nil {
@ -775,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)
@ -952,6 +968,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 +977,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 +998,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 +1010,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")
}

View File

@ -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"
)
@ -70,14 +71,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,
@ -106,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()
@ -217,13 +230,29 @@ 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,
}
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)

View File

@ -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"
@ -269,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()
@ -283,16 +307,34 @@ 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)
// 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)
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)
}
if !strings.Contains(run.Message, "using -target") {
t.Errorf("incorrect Message on the created run: %s", run.Message)
}
}
}

View File

@ -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")

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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).

View File

@ -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

View File

@ -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=

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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(&parameterID) {
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(&parameterID) {
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(&parameterID) {
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)
}

View File

@ -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 {

177
vendor/github.com/hashicorp/go-tfe/run_trigger.go generated vendored Normal file
View File

@ -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)
}

View File

@ -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.

View File

@ -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)

36
vendor/github.com/hashicorp/go-tfe/testing.go generated vendored Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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

2
vendor/modules.txt vendored
View File

@ -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