diff --git a/README.md b/README.md index 60c6df8bc..60b7fb569 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ For complete usage of the API client, see the [full package docs](https://pkg.go This API client covers most of the existing Terraform Cloud API calls and is updated regularly to add new or missing endpoints. - [x] Account +- [x] Agents - [x] Agent Pools - [x] Agent Tokens - [x] Applies diff --git a/agent.go b/agent.go new file mode 100644 index 000000000..aa48850df --- /dev/null +++ b/agent.go @@ -0,0 +1,91 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ Agents = (*agents)(nil) + +// Agents describes all the agent-related methods that the +// Terraform Cloud API supports. +// TFE API docs: https://www.terraform.io/docs/cloud/api/agents.html +type Agents interface { + // Read an agent by its ID. + Read(ctx context.Context, agentID string) (*Agent, error) + + // List all the agents of the given pool. + List(ctx context.Context, agentPoolID string, options *AgentListOptions) (*AgentList, error) +} + +// agents implements Agents. +type agents struct { + client *Client +} + +// AgentList represents a list of agents. +type AgentList struct { + *Pagination + Items []*Agent +} + +// Agent represents a Terraform Cloud agent. +type Agent struct { + ID string `jsonapi:"primary,agents"` + Name string `jsonapi:"attr,name"` + IP string `jsonapi:"attr,ip-address"` + Status string `jsonapi:"attr,status"` + LastPingAt string `jsonapi:"attr,last-ping-at"` +} + +type AgentListOptions struct { + ListOptions + + //Optional: + LastPingSince time.Time `url:"filter[last-ping-since],omitempty,iso8601"` +} + +// Read a single agent by its ID +func (s *agents) Read(ctx context.Context, agentID string) (*Agent, error) { + if !validStringID(&agentID) { + return nil, ErrInvalidAgentID + } + + u := fmt.Sprintf("agents/%s", url.QueryEscape(agentID)) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + + agent := &Agent{} + err = req.Do(ctx, agent) + if err != nil { + return nil, err + } + + return agent, nil +} + +// List all the agents of the given organization. +func (s *agents) List(ctx context.Context, agentPoolID string, options *AgentListOptions) (*AgentList, error) { + if !validStringID(&agentPoolID) { + return nil, ErrInvalidOrg + } + + u := fmt.Sprintf("agent-pools/%s/agents", url.QueryEscape(agentPoolID)) + req, err := s.client.NewRequest("GET", u, options) + if err != nil { + return nil, err + } + + agentList := &AgentList{} + err = req.Do(ctx, agentList) + if err != nil { + return nil, err + } + + return agentList, nil +} diff --git a/agent_integration_test.go b/agent_integration_test.go new file mode 100644 index 000000000..78175adeb --- /dev/null +++ b/agent_integration_test.go @@ -0,0 +1,75 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentsRead(t *testing.T) { + skipIfNotLinuxAmd64(t) + + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + agent, _, agentCleanup := createAgent(t, client, org) + + t.Cleanup(agentCleanup) + + t.Run("when the agent exists", func(t *testing.T) { + k, err := client.Agents.Read(ctx, agent.ID) + require.NoError(t, err) + assert.Equal(t, agent, k) + }) + + t.Run("when the agent does not exist", func(t *testing.T) { + k, err := client.Agents.Read(ctx, "nonexistent") + assert.Nil(t, k) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("without a valid agent ID", func(t *testing.T) { + k, err := client.Agents.Read(ctx, badIdentifier) + assert.Nil(t, k) + assert.EqualError(t, err, ErrInvalidAgentID.Error()) + }) +} + +func TestAgentsList(t *testing.T) { + skipIfNotLinuxAmd64(t) + + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + _, agentPool, agentCleanup := createAgent(t, client, org) + t.Cleanup(agentCleanup) + + t.Run("expect an agent to exist", func(t *testing.T) { + agent, err := client.Agents.List(ctx, agentPool.ID, nil) + + require.NoError(t, err) + require.NotEmpty(t, agent.Items) + assert.NotEmpty(t, agent.Items[0].ID) + }) + + t.Run("without a valid agent pool ID", func(t *testing.T) { + agent, err := client.Agents.List(ctx, badIdentifier, nil) + assert.Nil(t, agent) + assert.EqualError(t, err, ErrInvalidOrg.Error()) + }) +} diff --git a/agent_pool.go b/agent_pool.go index 1606b3d31..7d4ad2441 100644 --- a/agent_pool.go +++ b/agent_pool.go @@ -20,10 +20,10 @@ type AgentPools interface { // 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 an agent pool by its ID. Read(ctx context.Context, agentPoolID string) (*AgentPool, error) - // Read a agent pool by its ID with the given options. + // Read an agent pool by its ID with the given options. ReadWithOptions(ctx context.Context, agentPoolID string, options *AgentPoolReadOptions) (*AgentPool, error) // Update an agent pool by its ID. diff --git a/errors.go b/errors.go index 013436564..a6aa90e10 100644 --- a/errors.go +++ b/errors.go @@ -176,6 +176,8 @@ var ( ErrInvalidArch = errors.New("invalid value for arch") + ErrInvalidAgentID = errors.New("invalid value for Agent ID") + ErrInvalidRegistryName = errors.New(`invalid value for registry-name. It must be either "private" or "public"`) ) diff --git a/generate_mocks.sh b/generate_mocks.sh index 48e0f7007..e2beaf1d3 100755 --- a/generate_mocks.sh +++ b/generate_mocks.sh @@ -60,3 +60,4 @@ mockgen -source=variable_set.go -destination=mocks/variable_set_mocks.go -packag mockgen -source=variable_set_variable.go -destination=mocks/variable_set_variable_mocks.go -package=mocks mockgen -source=workspace.go -destination=mocks/workspace_mocks.go -package=mocks mockgen -source=workspace_run_task.go -destination=mocks/workspace_run_tasks_mocks.go -package=mocks +mockgen -source=agent.go -destination=mocks/agents.go -package=mocks diff --git a/helper_test.go b/helper_test.go index cd04a801c..2fb4288e1 100644 --- a/helper_test.go +++ b/helper_test.go @@ -1,6 +1,7 @@ package tfe import ( + "archive/zip" "context" "crypto/hmac" "crypto/md5" @@ -12,8 +13,13 @@ import ( "io" "io/ioutil" "math/rand" + "net/http" "net/url" "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "testing" "time" @@ -23,6 +29,7 @@ import ( ) const badIdentifier = "! / nope" //nolint +const agentVersion = "1.3.0" // Memoize test account details var _testAccountDetails *TestAccountDetails @@ -86,6 +93,187 @@ func fetchTestAccountDetails(t *testing.T, client *Client) *TestAccountDetails { return _testAccountDetails } +func downloadFile(filepath string, url string) error { + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Create the file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + return err +} + +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer func() { + if err := r.Close(); err != nil { + panic(err) + } + }() + + os.MkdirAll(dest, 0755) + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer func() { + if err := rc.Close(); err != nil { + panic(err) + } + }() + + path := filepath.Join(dest, f.Name) + + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", path) + } + + if f.FileInfo().IsDir() { + os.MkdirAll(path, f.Mode()) + } else { + os.MkdirAll(filepath.Dir(path), f.Mode()) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + return nil + } + + for _, f := range r.File { + err := extractAndWriteFile(f) + if err != nil { + return err + } + } + + return nil +} + +func downloadTFCAgent(t *testing.T) (string, error) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "tfc-agent") + if err != nil { + return "", fmt.Errorf("cannot create temp dir: %w", err) + } + t.Cleanup(func() { + fmt.Printf("cleaning up %s \n", tmpDir) + os.RemoveAll(tmpDir) + }) + agentPath := fmt.Sprintf("https://releases.hashicorp.com/tfc-agent/%s/tfc-agent_%s_linux_amd64.zip", agentVersion, agentVersion) + zipFile := fmt.Sprintf("%s/agent.zip", tmpDir) + + if err = downloadFile(zipFile, agentPath); err != nil { + return "", fmt.Errorf("cannot download agent file: %w", err) + } + + if err = unzip(zipFile, tmpDir); err != nil { + return "", fmt.Errorf("cannot unzip file: %w", err) + } + return fmt.Sprintf("%s/tfc-agent", tmpDir), nil +} + +func createAgent(t *testing.T, client *Client, org *Organization) (*Agent, *AgentPool, func()) { + var orgCleanup func() + var agentPoolTokenCleanup func() + var agent *Agent + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, org) + + upgradeOrganizationSubscription(t, client, org) + + agentPoolToken, agentPoolTokenCleanup := createAgentToken(t, client, agentPool) + + cleanup := func() { + agentPoolTokenCleanup() + + if agentPoolCleanup != nil { + agentPoolCleanup() + } + + if orgCleanup != nil { + orgCleanup() + } + } + + agentPath, err := downloadTFCAgent(t) + if err != nil { + return agent, agentPool, cleanup + } + + ctx := context.Background() + + cmd := exec.Command(agentPath) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "TFC_AGENT_TOKEN="+agentPoolToken.Token) + cmd.Env = append(cmd.Env, "TFC_AGENT_NAME="+"test-agent") + cmd.Env = append(cmd.Env, "TFC_ADDRESS="+DefaultConfig().Address) + + go func() { + _, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Could not run container: %s", err) + } + }() + + t.Cleanup(func() { + cmd.Process.Kill() + }) + + i, err := retry(func() (interface{}, error) { + + agentList, err := client.Agents.List(ctx, agentPool.ID, nil) + if err != nil { + return nil, err + } + + if agentList != nil && len(agentList.Items) > 0 { + return agentList.Items[0], nil + } + return nil, errors.New("no agent found") + }) + + if err != nil { + t.Fatalf("Could not return an agent %s", err) + } + + agent = i.(*Agent) + + return agent, agentPool, cleanup +} + func createAgentPool(t *testing.T, client *Client, org *Organization) (*AgentPool, func()) { var orgCleanup func() @@ -1765,6 +1953,17 @@ func skipIfBeta(t *testing.T) { } } +// skips a test if the architecture is not linux_amd64 +func skipIfNotLinuxAmd64(t *testing.T) { + if !linuxAmd64() { + t.Skip("Skipping test if architecture is not linux_amd64") + } +} + +func linuxAmd64() bool { + return runtime.GOOS == "linux" && runtime.GOARCH == "amd64" +} + // Checks to see if ENABLE_TFE is set to 1, thereby enabling enterprise tests. func enterpriseEnabled() bool { return os.Getenv("ENABLE_TFE") == "1" diff --git a/mocks/agents.go b/mocks/agents.go new file mode 100644 index 000000000..9fced7a8b --- /dev/null +++ b/mocks/agents.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: agent.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + tfe "github.com/hashicorp/go-tfe" +) + +// MockAgents is a mock of Agents interface. +type MockAgents struct { + ctrl *gomock.Controller + recorder *MockAgentsMockRecorder +} + +// MockAgentsMockRecorder is the mock recorder for MockAgents. +type MockAgentsMockRecorder struct { + mock *MockAgents +} + +// NewMockAgents creates a new mock instance. +func NewMockAgents(ctrl *gomock.Controller) *MockAgents { + mock := &MockAgents{ctrl: ctrl} + mock.recorder = &MockAgentsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAgents) EXPECT() *MockAgentsMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockAgents) List(ctx context.Context, agentPoolID string, options *tfe.AgentListOptions) (*tfe.AgentList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, agentPoolID, options) + ret0, _ := ret[0].(*tfe.AgentList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockAgentsMockRecorder) List(ctx, agentPoolID, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockAgents)(nil).List), ctx, agentPoolID, options) +} + +// Read mocks base method. +func (m *MockAgents) Read(ctx context.Context, agentID string) (*tfe.Agent, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", ctx, agentID) + ret0, _ := ret[0].(*tfe.Agent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockAgentsMockRecorder) Read(ctx, agentID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockAgents)(nil).Read), ctx, agentID) +} diff --git a/tfe.go b/tfe.go index fb63406bc..3a3eb59be 100644 --- a/tfe.go +++ b/tfe.go @@ -117,6 +117,7 @@ type Client struct { remoteAPIVersion string Admin Admin + Agents Agents AgentPools AgentPools AgentTokens AgentTokens Applies Applies @@ -351,6 +352,7 @@ func NewClient(cfg *Config) (*Client, error) { } // Create the services. + client.Agents = &agents{client: client} client.AgentPools = &agentPools{client: client} client.AgentTokens = &agentTokens{client: client} client.Applies = &applies{client: client}