diff --git a/agent_pool.go b/agent_pool.go new file mode 100644 index 000000000..6ee141643 --- /dev/null +++ b/agent_pool.go @@ -0,0 +1,205 @@ +package tfe + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ AgentPools = (*agentPools)(nil) + +// AgentPools describes all the agent pool related methods that the Terraform +// Cloud API supports. Note that agents are not available in Terraform Enterprise. +// +// TFE API docs: https://www.terraform.io/docs/cloud/api/agents.html +type AgentPools interface { + // List all the agent pools of the given organization. + List(ctx context.Context, organization string, options AgentPoolListOptions) (*AgentPoolList, error) + + // Create a new agent pool with the given options. + Create(ctx context.Context, organization string, options AgentPoolCreateOptions) (*AgentPool, error) + + // Read a agent pool by its ID. + Read(ctx context.Context, agentPoolID string) (*AgentPool, error) + + // Update an agent pool by its ID. + Update(ctx context.Context, agentPool string, options AgentPoolUpdateOptions) (*AgentPool, error) + + // Delete an agent pool by its ID. + Delete(ctx context.Context, agentPoolID string) error +} + +// agentPools implements AgentPools. +type agentPools struct { + client *Client +} + +// AgentPoolList represents a list of agent pools. +type AgentPoolList struct { + *Pagination + Items []*AgentPool +} + +// AgentPool represents a Terraform Cloud agent pool. +type AgentPool struct { + ID string `jsonapi:"primary,agent-pools"` + Name string `jsonapi:"attr,name"` + + // Relations + Organization *Organization `jsonapi:"relation,organization"` +} + +// AgentPoolListOptions represents the options for listing agent pools. +type AgentPoolListOptions struct { + ListOptions +} + +// List all the agent pools of the given organization. +func (s *agentPools) List(ctx context.Context, organization string, options AgentPoolListOptions) (*AgentPoolList, error) { + if !validStringID(&organization) { + return nil, errors.New("invalid value for organization") + } + + u := fmt.Sprintf("organizations/%s/agent-pools", url.QueryEscape(organization)) + req, err := s.client.newRequest("GET", u, &options) + if err != nil { + return nil, err + } + + poolList := &AgentPoolList{} + err = s.client.do(ctx, req, poolList) + if err != nil { + return nil, err + } + + return poolList, nil +} + +// AgentPoolCreateOptions represents the options for creating an agent pool. +type AgentPoolCreateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,agent-pools"` + + // A name to identify the agent pool. + Name *string `jsonapi:"attr,name"` +} + +func (o AgentPoolCreateOptions) valid() error { + if !validString(o.Name) { + return errors.New("name is required") + } + if !validStringID(o.Name) { + return errors.New("invalid value for name") + } + return nil +} + +// Create a new agent pool with the given options. +func (s *agentPools) Create(ctx context.Context, organization string, options AgentPoolCreateOptions) (*AgentPool, error) { + if !validStringID(&organization) { + return nil, errors.New("invalid value for organization") + } + + if err := options.valid(); err != nil { + return nil, err + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("organizations/%s/agent-pools", url.QueryEscape(organization)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + pool := &AgentPool{} + err = s.client.do(ctx, req, pool) + if err != nil { + return nil, err + } + + return pool, nil +} + +// Read a single agent pool by its ID. +func (s *agentPools) Read(ctx context.Context, agentpoolID string) (*AgentPool, error) { + if !validStringID(&agentpoolID) { + return nil, errors.New("invalid value for agent pool ID") + } + + u := fmt.Sprintf("agent-pools/%s", url.QueryEscape(agentpoolID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + pool := &AgentPool{} + err = s.client.do(ctx, req, pool) + if err != nil { + return nil, err + } + + return pool, nil +} + +// AgentPoolUpdateOptions represents the options for updating an agent pool. +type AgentPoolUpdateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,agent-pools"` + + // A new name to identify the agent pool. + Name *string `jsonapi:"attr,name"` +} + +func (o AgentPoolUpdateOptions) valid() error { + if o.Name != nil && !validStringID(o.Name) { + return errors.New("invalid value for name") + } + return nil +} + +// Update an agent pool by its ID. +func (s *agentPools) Update(ctx context.Context, agentPoolID string, options AgentPoolUpdateOptions) (*AgentPool, error) { + if !validStringID(&agentPoolID) { + return nil, errors.New("invalid value for agent pool 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("agent-pools/%s", url.QueryEscape(agentPoolID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + k := &AgentPool{} + err = s.client.do(ctx, req, k) + if err != nil { + return nil, err + } + + return k, nil +} + +// Delete an agent pool by its ID. +func (s *agentPools) Delete(ctx context.Context, agentPoolID string) error { + if !validStringID(&agentPoolID) { + return errors.New("invalid value for agent pool ID") + } + + u := fmt.Sprintf("agent-pools/%s", url.QueryEscape(agentPoolID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/agent_pool_test.go b/agent_pool_test.go new file mode 100644 index 000000000..8f0d61187 --- /dev/null +++ b/agent_pool_test.go @@ -0,0 +1,189 @@ +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentPoolsList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + agentPool, _ := createAgentPool(t, client, orgTest) + + t.Run("without list options", func(t *testing.T) { + pools, err := client.AgentPools.List(ctx, orgTest.Name, AgentPoolListOptions{}) + require.NoError(t, err) + assert.Contains(t, pools.Items, agentPool) + + assert.Equal(t, 1, pools.CurrentPage) + assert.Equal(t, 1, pools.TotalCount) + }) + + t.Run("with list options", func(t *testing.T) { + // Request a page number which is out of range. The result should + // be successful, but return no results if the paging options are + // properly passed along. + pools, err := client.AgentPools.List(ctx, orgTest.Name, AgentPoolListOptions{ + ListOptions: ListOptions{ + PageNumber: 999, + PageSize: 100, + }, + }) + require.NoError(t, err) + assert.Empty(t, pools.Items) + assert.Equal(t, 999, pools.CurrentPage) + assert.Equal(t, 1, pools.TotalCount) + }) + + t.Run("without a valid organization", func(t *testing.T) { + pools, err := client.AgentPools.List(ctx, badIdentifier, AgentPoolListOptions{}) + assert.Nil(t, pools) + assert.EqualError(t, err, "invalid value for organization") + }) +} + +func TestAgentPoolsCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + options := AgentPoolCreateOptions{ + Name: String("cool-pool"), + } + + pool, err := client.AgentPools.Create(ctx, orgTest.Name, options) + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.AgentPools.Read(ctx, pool.ID) + require.NoError(t, err) + + for _, item := range []*AgentPool{ + pool, + refreshed, + } { + assert.NotEmpty(t, item.ID) + } + }) + + t.Run("when options is missing name", func(t *testing.T) { + k, err := client.AgentPools.Create(ctx, "foo", AgentPoolCreateOptions{}) + assert.Nil(t, k) + assert.EqualError(t, err, "name is required") + }) + + t.Run("with an invalid organization", func(t *testing.T) { + pool, err := client.AgentPools.Create(ctx, badIdentifier, AgentPoolCreateOptions{ + Name: String("cool-pool"), + }) + assert.Nil(t, pool) + assert.EqualError(t, err, "invalid value for organization") + }) +} + +func TestAgentPoolsRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + pool, _ := createAgentPool(t, client, orgTest) + + t.Run("when the agent pool exists", func(t *testing.T) { + k, err := client.AgentPools.Read(ctx, pool.ID) + require.NoError(t, err) + assert.Equal(t, pool, k) + }) + + t.Run("when the agent pool does not exist", func(t *testing.T) { + k, err := client.AgentPools.Read(ctx, "nonexisting") + assert.Nil(t, k) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("without a valid agent pool ID", func(t *testing.T) { + k, err := client.AgentPools.Read(ctx, badIdentifier) + assert.Nil(t, k) + assert.EqualError(t, err, "invalid value for agent pool ID") + }) +} + +func TestAgentPoolsUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + kBefore, kTestCleanup := createAgentPool(t, client, orgTest) + defer kTestCleanup() + + kAfter, err := client.AgentPools.Update(ctx, kBefore.ID, AgentPoolUpdateOptions{ + Name: String(randomString(t)), + }) + require.NoError(t, err) + + assert.Equal(t, kBefore.ID, kAfter.ID) + assert.NotEqual(t, kBefore.Name, kAfter.Name) + }) + + t.Run("when updating the name", func(t *testing.T) { + kBefore, kTestCleanup := createAgentPool(t, client, orgTest) + defer kTestCleanup() + + kAfter, err := client.AgentPools.Update(ctx, kBefore.ID, AgentPoolUpdateOptions{ + Name: String("updated-key-name"), + }) + require.NoError(t, err) + + assert.Equal(t, kBefore.ID, kAfter.ID) + assert.Equal(t, "updated-key-name", kAfter.Name) + }) + + t.Run("without a valid agent pool ID", func(t *testing.T) { + w, err := client.AgentPools.Update(ctx, badIdentifier, AgentPoolUpdateOptions{}) + assert.Nil(t, w) + assert.EqualError(t, err, "invalid value for agent pool ID") + }) +} + +func TestAgentPoolsDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + kTest, _ := createAgentPool(t, client, orgTest) + + t.Run("with valid options", func(t *testing.T) { + err := client.AgentPools.Delete(ctx, kTest.ID) + require.NoError(t, err) + + // Try loading the agent pool - it should fail. + _, err = client.AgentPools.Read(ctx, kTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the agent pool does not exist", func(t *testing.T) { + err := client.AgentPools.Delete(ctx, kTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the agent pool ID is invalid", func(t *testing.T) { + err := client.AgentPools.Delete(ctx, badIdentifier) + assert.EqualError(t, err, "invalid value for agent pool ID") + }) +} diff --git a/helper_test.go b/helper_test.go index 54180327b..950602d37 100644 --- a/helper_test.go +++ b/helper_test.go @@ -34,6 +34,34 @@ func fetchTestAccountDetails(t *testing.T, client *Client) *TestAccountDetails { return _testAccountDetails } +func createAgentPool(t *testing.T, client *Client, org *Organization) (*AgentPool, func()) { + var orgCleanup func() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + ctx := context.Background() + pool, err := client.AgentPools.Create(ctx, org.Name, AgentPoolCreateOptions{ + Name: String(randomString(t)), + }) + if err != nil { + t.Fatal(err) + } + + return pool, func() { + if err := client.AgentPools.Delete(ctx, pool.ID); err != nil { + t.Errorf("Error destroying agent pool! WARNING: Dangling resources "+ + "may exist! The full error is shown below.\n\n"+ + "Agent pool ID: %s\nError: %s", pool.ID, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + func createConfigurationVersion(t *testing.T, client *Client, w *Workspace) (*ConfigurationVersion, func()) { var wCleanup func() diff --git a/tfe.go b/tfe.go index ba7560936..4e3f4f37d 100644 --- a/tfe.go +++ b/tfe.go @@ -108,6 +108,7 @@ type Client struct { retryServerErrors bool remoteAPIVersion string + AgentPools AgentPools Applies Applies ConfigurationVersions ConfigurationVersions CostEstimates CostEstimates @@ -211,6 +212,7 @@ func NewClient(cfg *Config) (*Client, error) { client.remoteAPIVersion = meta.APIVersion // Create the services. + client.AgentPools = &agentPools{client: client} client.Applies = &applies{client: client} client.ConfigurationVersions = &configurationVersions{client: client} client.CostEstimates = &costEstimates{client: client} diff --git a/workspace.go b/workspace.go index 66f2d273e..cc45bad3d 100644 --- a/workspace.go +++ b/workspace.go @@ -77,10 +77,12 @@ type WorkspaceList struct { type Workspace struct { ID string `jsonapi:"primary,workspaces"` Actions *WorkspaceActions `jsonapi:"attr,actions"` + AgentPoolID string `jsonapi:"attr,agent-pool-id"` AutoApply bool `jsonapi:"attr,auto-apply"` CanQueueDestroyPlan bool `jsonapi:"attr,can-queue-destroy-plan"` CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` Environment string `jsonapi:"attr,environment"` + ExecutionMode string `jsonapi:"attr,execution-mode"` FileTriggersEnabled bool `jsonapi:"attr,file-triggers-enabled"` Locked bool `jsonapi:"attr,locked"` MigrationEnvironment string `jsonapi:"attr,migration-environment"` @@ -95,6 +97,7 @@ type Workspace struct { WorkingDirectory string `jsonapi:"attr,working-directory"` // Relations + AgentPool *AgentPool `jsonapi:"relation,agent-pool"` CurrentRun *Run `jsonapi:"relation,current-run"` Organization *Organization `jsonapi:"relation,organization"` SSHKey *SSHKey `jsonapi:"relation,ssh-key"` @@ -165,9 +168,20 @@ type WorkspaceCreateOptions struct { // For internal use only! ID string `jsonapi:"primary,workspaces"` + // Required when execution-mode is set to agent. The ID of the agent pool + // belonging to the workspace's organization. This value must not be specified + // if execution-mode is set to remote or local or if operations is set to true. + AgentPoolID *string `jsonapi:"attr,agent-pool-id,omitempty"` + // Whether to automatically apply changes when a Terraform plan is successful. AutoApply *bool `jsonapi:"attr,auto-apply,omitempty"` + // Which execution mode to use. Valid values are remote, local, and agent. + // When set to local, the workspace will be used for state storage only. + // This value must not be specified if operations is specified. + // 'agent' execution mode is not available in Terraform Enterprise. + ExecutionMode *string `jsonapi:"attr,execution-mode,omitempty"` + // Whether to filter runs based on the changed files in a VCS push. If // enabled, the working directory and trigger prefixes describe a set of // paths which must contain changes for a VCS push to trigger a run. If @@ -184,7 +198,8 @@ type WorkspaceCreateOptions struct { // organization. Name *string `jsonapi:"attr,name"` - // Whether the workspace will use remote or local execution mode. + // DEPRECATED. Whether the workspace will use remote or local execution mode. + // Use ExecutionMode instead. Operations *bool `jsonapi:"attr,operations,omitempty"` // Whether to queue all runs. Unless this is set to true, runs triggered by @@ -232,6 +247,16 @@ func (o WorkspaceCreateOptions) valid() error { if !validStringID(o.Name) { return errors.New("invalid value for name") } + if o.Operations != nil && o.ExecutionMode != nil { + return errors.New("operations is deprecated and cannot be specified when execution mode is used") + } + if o.AgentPoolID != nil && (o.ExecutionMode == nil || *o.ExecutionMode != "agent") { + return errors.New("specifying an agent pool ID requires 'agent' execution mode") + } + if o.AgentPoolID == nil && (o.ExecutionMode != nil && *o.ExecutionMode == "agent") { + return errors.New("'agent' execution mode requires an agent pool ID to be specified") + } + return nil } @@ -316,6 +341,11 @@ type WorkspaceUpdateOptions struct { // For internal use only! ID string `jsonapi:"primary,workspaces"` + // Required when execution-mode is set to agent. The ID of the agent pool + // belonging to the workspace's organization. This value must not be specified + // if execution-mode is set to remote or local or if operations is set to true. + AgentPoolID *string `jsonapi:"attr,agent-pool-id,omitempty"` + // Whether to automatically apply changes when a Terraform plan is successful. AutoApply *bool `jsonapi:"attr,auto-apply,omitempty"` @@ -325,13 +355,20 @@ type WorkspaceUpdateOptions struct { // API and UI. Name *string `jsonapi:"attr,name,omitempty"` + // Which execution mode to use. Valid values are remote, local, and agent. + // When set to local, the workspace will be used for state storage only. + // This value must not be specified if operations is specified. + // 'agent' execution mode is not available in Terraform Enterprise. + ExecutionMode *string `jsonapi:"attr,execution-mode,omitempty"` + // Whether to filter runs based on the changed files in a VCS push. If // enabled, the working directory and trigger prefixes describe a set of // paths which must contain changes for a VCS push to trigger a run. If // disabled, any push will trigger a run. FileTriggersEnabled *bool `jsonapi:"attr,file-triggers-enabled,omitempty"` - // Whether the workspace will use remote or local execution mode. + // DEPRECATED. Whether the workspace will use remote or local execution mode. + // Use ExecutionMode instead. Operations *bool `jsonapi:"attr,operations,omitempty"` // Whether to queue all runs. Unless this is set to true, runs triggered by @@ -365,6 +402,20 @@ type WorkspaceUpdateOptions struct { WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"` } +func (o WorkspaceUpdateOptions) valid() error { + if o.Name != nil && !validStringID(o.Name) { + return errors.New("invalid value for name") + } + if o.Operations != nil && o.ExecutionMode != nil { + return errors.New("operations is deprecated and cannot be specified when execution mode is used") + } + if o.AgentPoolID == nil && (o.ExecutionMode != nil && *o.ExecutionMode == "agent") { + return errors.New("'agent' execution mode requires an agent pool ID to be specified") + } + + return nil +} + // Update settings of an existing workspace. func (s *workspaces) Update(ctx context.Context, organization, workspace string, options WorkspaceUpdateOptions) (*Workspace, error) { if !validStringID(&organization) { @@ -373,6 +424,9 @@ func (s *workspaces) Update(ctx context.Context, organization, workspace string, if !validStringID(&workspace) { return nil, errors.New("invalid value for workspace") } + if err := options.valid(); err != nil { + return nil, err + } // Make sure we don't send a user provided ID. options.ID = "" diff --git a/workspace_test.go b/workspace_test.go index 54f379193..fb2da0d9f 100644 --- a/workspace_test.go +++ b/workspace_test.go @@ -155,7 +155,41 @@ func TestWorkspacesCreate(t *testing.T) { assert.EqualError(t, err, "invalid value for organization") }) - t.Run("when an error is returned from the api", func(t *testing.T) { + t.Run("when options includes both an operations value and an enforcement mode value", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String("foo"), + ExecutionMode: String("remote"), + Operations: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, orgTest.Name, options) + assert.Nil(t, w) + assert.EqualError(t, err, "operations is deprecated and cannot be specified when execution mode is used") + }) + + t.Run("when an agent pool ID is specified without 'agent' execution mode", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String("foo"), + AgentPoolID: String("apool-xxxxx"), + } + + w, err := client.Workspaces.Create(ctx, orgTest.Name, options) + assert.Nil(t, w) + assert.EqualError(t, err, "specifying an agent pool ID requires 'agent' execution mode") + }) + + t.Run("when 'agent' execution mode is specified without an an agent pool ID", func(t *testing.T) { + options := WorkspaceCreateOptions{ + Name: String("foo"), + ExecutionMode: String("agent"), + } + + w, err := client.Workspaces.Create(ctx, orgTest.Name, options) + assert.Nil(t, w) + assert.EqualError(t, err, "'agent' execution mode requires an agent pool ID to be specified") + }) + + t.Run("when an error is returned from the API", func(t *testing.T) { w, err := client.Workspaces.Create(ctx, "bar", WorkspaceCreateOptions{ Name: String("bar"), TerraformVersion: String("nonexisting"), @@ -323,6 +357,27 @@ func TestWorkspacesUpdate(t *testing.T) { } }) + t.Run("when options includes both an operations value and an enforcement mode value", func(t *testing.T) { + options := WorkspaceUpdateOptions{ + ExecutionMode: String("remote"), + Operations: Bool(true), + } + + wAfter, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, options) + assert.Nil(t, wAfter) + assert.EqualError(t, err, "operations is deprecated and cannot be specified when execution mode is used") + }) + + t.Run("when 'agent' execution mode is specified without an an agent pool ID", func(t *testing.T) { + options := WorkspaceUpdateOptions{ + ExecutionMode: String("agent"), + } + + wAfter, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, options) + assert.Nil(t, wAfter) + assert.EqualError(t, err, "'agent' execution mode requires an agent pool ID to be specified") + }) + t.Run("when an error is returned from the api", func(t *testing.T) { w, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, WorkspaceUpdateOptions{ TerraformVersion: String("nonexisting"),